x
This commit is contained in:
1 parent
d5ea45a536
commit
afd6904c48
20 files changed
+5435
-51
No files matched your search
+90
@@ -0,0 +1,90 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MarketplaceCustomer" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||
"identifier" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"province" TEXT,
|
||||
"district" TEXT,
|
||||
"orderCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalSpent" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"avgIntervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"firstOrderDate" TEXT,
|
||||
"lastOrderDate" TEXT,
|
||||
"predictedNextOrderDate" TEXT,
|
||||
"topProducts" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketplaceCustomer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MarketplaceCustomerCrm" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"customerId" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"strategyType" TEXT NOT NULL DEFAULT 'due_restock',
|
||||
"promoCodeSent" TEXT,
|
||||
"discountOffered" TEXT,
|
||||
"lastContactedAt" TIMESTAMP(3),
|
||||
"nextFollowUpDate" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"assignedTo" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketplaceCustomerCrm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "CustomerCrmActivity" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"crmId" INTEGER NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"author" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerCrmActivity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_idx" ON "MarketplaceCustomer"("channel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_totalSpent_idx" ON "MarketplaceCustomer"("totalSpent");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_orderCount_idx" ON "MarketplaceCustomer"("orderCount");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_identifier_key" ON "MarketplaceCustomer"("channel", "identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_customerId_key" ON "MarketplaceCustomerCrm"("customerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_status_idx" ON "MarketplaceCustomerCrm"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_strategyType_idx" ON "MarketplaceCustomerCrm"("strategyType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_nextFollowUpDate_idx" ON "MarketplaceCustomerCrm"("nextFollowUpDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "CustomerCrmActivity_crmId_idx" ON "CustomerCrmActivity"("crmId");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MarketplaceCustomerCrm_customerId_fkey') THEN
|
||||
ALTER TABLE "MarketplaceCustomerCrm" ADD CONSTRAINT "MarketplaceCustomerCrm_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "MarketplaceCustomer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CustomerCrmActivity_crmId_fkey') THEN
|
||||
ALTER TABLE "CustomerCrmActivity" ADD CONSTRAINT "CustomerCrmActivity_crmId_fkey" FOREIGN KEY ("crmId") REFERENCES "MarketplaceCustomerCrm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -790,3 +790,75 @@ model MarketplaceProduct {
|
||||
@@index([totalRevenue])
|
||||
}
|
||||
|
||||
// ── Marketplace Customers & CRM ───────────────────────────────────────────────
|
||||
model MarketplaceCustomer {
|
||||
id Int @id @default(autoincrement())
|
||||
channel String // "tiktok" | "shopee" | "lazada"
|
||||
shop String @default("hom-aroma")
|
||||
identifier String // Unique identifier per channel (username or name+postcode)
|
||||
displayName String // Display name on UI
|
||||
location String? // District, Province
|
||||
province String?
|
||||
district String?
|
||||
|
||||
// Aggregated buyer stats
|
||||
orderCount Int @default(0)
|
||||
totalSpent Float @default(0)
|
||||
avgIntervalDays Float @default(0)
|
||||
firstOrderDate String?
|
||||
lastOrderDate String?
|
||||
predictedNextOrderDate String?
|
||||
topProducts Json? // Stored as JSON array: string[]
|
||||
|
||||
// CRM Relationship
|
||||
crm MarketplaceCustomerCrm?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([channel, identifier])
|
||||
@@index([channel])
|
||||
@@index([totalSpent])
|
||||
@@index([orderCount])
|
||||
}
|
||||
|
||||
model MarketplaceCustomerCrm {
|
||||
id Int @id @default(autoincrement())
|
||||
customerId Int @unique
|
||||
customer MarketplaceCustomer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Tracking status
|
||||
status String @default("pending") // "pending" | "contacted" | "converted" | "unresponsive" | "do_not_disturb"
|
||||
strategyType String @default("due_restock") // "due_restock" | "win_back" | "vip_reward" | "nurture"
|
||||
|
||||
// Campaign & contact details
|
||||
promoCodeSent String? // e.g., "REPEAT10"
|
||||
discountOffered String? // e.g., "ส่วนลด 10%"
|
||||
lastContactedAt DateTime?
|
||||
nextFollowUpDate DateTime?
|
||||
notes String?
|
||||
assignedTo String?
|
||||
|
||||
activities CustomerCrmActivity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([strategyType])
|
||||
@@index([nextFollowUpDate])
|
||||
}
|
||||
|
||||
model CustomerCrmActivity {
|
||||
id Int @id @default(autoincrement())
|
||||
crmId Int
|
||||
crm MarketplaceCustomerCrm @relation(fields: [crmId], references: [id], onDelete: Cascade)
|
||||
|
||||
action String // "STATUS_CHANGE" | "SENT_PROMO" | "CALL_NOTE" | "CHAT_NOTE"
|
||||
content String
|
||||
author String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([crmId])
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { productAttributesRoute } from "./routes/product-attributes";
|
||||
import { backupRoute } from "./routes/backup";
|
||||
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
|
||||
import { toolsRoute } from "./routes/tools";
|
||||
import { crmRoute } from "./routes/crm";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -138,7 +139,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
@@ -1872,6 +1872,12 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
|
||||
.slice(0, 3)
|
||||
.map(([name]) => name);
|
||||
|
||||
let predictedNextOrderDate = "";
|
||||
if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) {
|
||||
const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000;
|
||||
predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
identifier: c.identifier,
|
||||
@@ -1885,6 +1891,7 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
|
||||
firstOrderDate: c.firstOrderDate,
|
||||
lastOrderDate: c.lastOrderDate,
|
||||
daysBetweenOrders: c.daysBetweenOrders,
|
||||
predictedNextOrderDate,
|
||||
location: c.location,
|
||||
topProducts: sortedProds,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const crmRoute = new Elysia({ prefix: "/crm" })
|
||||
// ── 1. Import Single / Multiple Customers ─────────────────────────────────
|
||||
.post(
|
||||
"/import",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { customers } = body as {
|
||||
customers: Array<{
|
||||
channel: string;
|
||||
shop?: string;
|
||||
identifier: string;
|
||||
displayName: string;
|
||||
location?: string;
|
||||
province?: string;
|
||||
district?: string;
|
||||
orderCount?: number;
|
||||
totalSpent?: number;
|
||||
avgIntervalDays?: number;
|
||||
firstOrderDate?: string;
|
||||
lastOrderDate?: string;
|
||||
predictedNextOrderDate?: string;
|
||||
topProducts?: string[];
|
||||
initialStatus?: string;
|
||||
strategyType?: string;
|
||||
promoCode?: string;
|
||||
discountOffered?: string;
|
||||
notes?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!customers || !Array.isArray(customers) || customers.length === 0) {
|
||||
set.status = 400;
|
||||
return { error: "customers array is required" };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const c of customers) {
|
||||
const channel = (c.channel || "tiktok").toLowerCase();
|
||||
const identifier = (c.identifier || "").trim();
|
||||
if (!identifier) continue;
|
||||
|
||||
// Upsert MarketplaceCustomer
|
||||
const cust = await prisma.marketplaceCustomer.upsert({
|
||||
where: {
|
||||
channel_identifier: {
|
||||
channel,
|
||||
identifier,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
displayName: c.displayName || identifier,
|
||||
shop: c.shop || "hom-aroma",
|
||||
location: c.location || undefined,
|
||||
province: c.province || undefined,
|
||||
district: c.district || undefined,
|
||||
orderCount: typeof c.orderCount === "number" ? c.orderCount : undefined,
|
||||
totalSpent: typeof c.totalSpent === "number" ? c.totalSpent : undefined,
|
||||
avgIntervalDays: typeof c.avgIntervalDays === "number" ? c.avgIntervalDays : undefined,
|
||||
firstOrderDate: c.firstOrderDate || undefined,
|
||||
lastOrderDate: c.lastOrderDate || undefined,
|
||||
predictedNextOrderDate: c.predictedNextOrderDate || undefined,
|
||||
topProducts: c.topProducts ? (c.topProducts as any) : undefined,
|
||||
},
|
||||
create: {
|
||||
channel,
|
||||
shop: c.shop || "hom-aroma",
|
||||
identifier,
|
||||
displayName: c.displayName || identifier,
|
||||
location: c.location || "",
|
||||
province: c.province || "",
|
||||
district: c.district || "",
|
||||
orderCount: c.orderCount || 0,
|
||||
totalSpent: c.totalSpent || 0,
|
||||
avgIntervalDays: c.avgIntervalDays || 0,
|
||||
firstOrderDate: c.firstOrderDate || "",
|
||||
lastOrderDate: c.lastOrderDate || "",
|
||||
predictedNextOrderDate: c.predictedNextOrderDate || "",
|
||||
topProducts: c.topProducts ? (c.topProducts as any) : [],
|
||||
},
|
||||
});
|
||||
|
||||
// Check if CRM record exists
|
||||
let crm = await prisma.marketplaceCustomerCrm.findUnique({
|
||||
where: { customerId: cust.id },
|
||||
});
|
||||
|
||||
if (!crm) {
|
||||
crm = await prisma.marketplaceCustomerCrm.create({
|
||||
data: {
|
||||
customerId: cust.id,
|
||||
status: c.initialStatus || "pending",
|
||||
strategyType: c.strategyType || "due_restock",
|
||||
promoCodeSent: c.promoCode || null,
|
||||
discountOffered: c.discountOffered || null,
|
||||
notes: c.notes || null,
|
||||
activities: {
|
||||
create: {
|
||||
action: "IMPORT",
|
||||
content: `นำเข้าลูกค้าจาก Action Plan (${channel})`,
|
||||
author: "System",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: cust.id,
|
||||
identifier: cust.identifier,
|
||||
channel: cust.channel,
|
||||
displayName: cust.displayName,
|
||||
crmId: crm.id,
|
||||
status: crm.status,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
importedCount: results.length,
|
||||
results,
|
||||
};
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to import customers" };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
customers: t.Array(
|
||||
t.Object({
|
||||
channel: t.String(),
|
||||
shop: t.Optional(t.String()),
|
||||
identifier: t.String(),
|
||||
displayName: t.String(),
|
||||
location: t.Optional(t.String()),
|
||||
province: t.Optional(t.String()),
|
||||
district: t.Optional(t.String()),
|
||||
orderCount: t.Optional(t.Number()),
|
||||
totalSpent: t.Optional(t.Number()),
|
||||
avgIntervalDays: t.Optional(t.Number()),
|
||||
firstOrderDate: t.Optional(t.String()),
|
||||
lastOrderDate: t.Optional(t.String()),
|
||||
predictedNextOrderDate: t.Optional(t.String()),
|
||||
topProducts: t.Optional(t.Array(t.String())),
|
||||
initialStatus: t.Optional(t.String()),
|
||||
strategyType: t.Optional(t.String()),
|
||||
promoCode: t.Optional(t.String()),
|
||||
discountOffered: t.Optional(t.String()),
|
||||
notes: t.Optional(t.String()),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ── 2. Check Which Customers are Already Imported ─────────────────────────
|
||||
.post(
|
||||
"/check-imported",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { keys } = body as {
|
||||
keys: Array<{ channel: string; identifier: string }>;
|
||||
};
|
||||
|
||||
if (!keys || !Array.isArray(keys) || keys.length === 0) {
|
||||
return { importedMap: {} };
|
||||
}
|
||||
|
||||
const conditions = keys.map((k) => ({
|
||||
channel: k.channel.toLowerCase(),
|
||||
identifier: k.identifier.trim(),
|
||||
}));
|
||||
|
||||
const existing = await prisma.marketplaceCustomer.findMany({
|
||||
where: {
|
||||
OR: conditions,
|
||||
},
|
||||
include: {
|
||||
crm: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Map key format: "channel:identifier" => { customerId, crmId, status, notes, promoCodeSent }
|
||||
const importedMap: Record<string, any> = {};
|
||||
for (const item of existing) {
|
||||
const mapKey = `${item.channel}:${item.identifier.toLowerCase()}`;
|
||||
importedMap[mapKey] = {
|
||||
id: item.id,
|
||||
channel: item.channel,
|
||||
identifier: item.identifier,
|
||||
displayName: item.displayName,
|
||||
crmId: item.crm?.id,
|
||||
status: item.crm?.status || "pending",
|
||||
notes: item.crm?.notes || "",
|
||||
promoCodeSent: item.crm?.promoCodeSent || "",
|
||||
discountOffered: item.crm?.discountOffered || "",
|
||||
strategyType: item.crm?.strategyType || "due_restock",
|
||||
updatedAt: item.crm?.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return { importedMap };
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to check imported customers" };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
keys: t.Array(
|
||||
t.Object({
|
||||
channel: t.String(),
|
||||
identifier: t.String(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ── 3. List CRM Customers with Stats, Filters, and Pagination ─────────────
|
||||
.get("/customers", async ({ query, set }) => {
|
||||
try {
|
||||
const channel = (query.channel as string) || "all";
|
||||
const status = (query.status as string) || "all";
|
||||
const strategyType = (query.strategyType as string) || "all";
|
||||
const search = (query.search as string) || "";
|
||||
const page = Math.max(1, parseInt((query.page as string) || "1"));
|
||||
const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20")));
|
||||
const sortBy = (query.sortBy as string) || "updatedAt"; // 'updatedAt' | 'totalSpent' | 'orderCount' | 'nextFollowUpDate'
|
||||
const sortOrder = (query.sortOrder as string) || "desc";
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (channel !== "all") {
|
||||
where.channel = channel;
|
||||
}
|
||||
|
||||
if (status !== "all") {
|
||||
where.crm = { ...(where.crm || {}), status };
|
||||
}
|
||||
|
||||
if (strategyType !== "all") {
|
||||
where.crm = { ...(where.crm || {}), strategyType };
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ displayName: { contains: search, mode: "insensitive" } },
|
||||
{ identifier: { contains: search, mode: "insensitive" } },
|
||||
{ location: { contains: search, mode: "insensitive" } },
|
||||
{ crm: { notes: { contains: search, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
|
||||
// Total count
|
||||
const totalCount = await prisma.marketplaceCustomer.count({ where });
|
||||
|
||||
// Build Order By
|
||||
let orderBy: any = {};
|
||||
if (sortBy === "totalSpent") {
|
||||
orderBy = { totalSpent: sortOrder };
|
||||
} else if (sortBy === "orderCount") {
|
||||
orderBy = { orderCount: sortOrder };
|
||||
} else if (sortBy === "nextFollowUpDate") {
|
||||
orderBy = { crm: { nextFollowUpDate: sortOrder } };
|
||||
} else {
|
||||
orderBy = { updatedAt: sortOrder };
|
||||
}
|
||||
|
||||
const customers = await prisma.marketplaceCustomer.findMany({
|
||||
where,
|
||||
include: {
|
||||
crm: {
|
||||
include: {
|
||||
activities: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Overall CRM summary metrics
|
||||
const [
|
||||
totalInCrm,
|
||||
pendingCount,
|
||||
contactedCount,
|
||||
convertedCount,
|
||||
unresponsiveCount,
|
||||
aggregateStats,
|
||||
] = await Promise.all([
|
||||
prisma.marketplaceCustomerCrm.count(),
|
||||
prisma.marketplaceCustomerCrm.count({ where: { status: "pending" } }),
|
||||
prisma.marketplaceCustomerCrm.count({ where: { status: "contacted" } }),
|
||||
prisma.marketplaceCustomerCrm.count({ where: { status: "converted" } }),
|
||||
prisma.marketplaceCustomerCrm.count({ where: { status: "unresponsive" } }),
|
||||
prisma.marketplaceCustomer.aggregate({
|
||||
_sum: { totalSpent: true },
|
||||
_avg: { avgIntervalDays: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const conversionRate =
|
||||
contactedCount + convertedCount > 0
|
||||
? Number(((convertedCount / (contactedCount + convertedCount)) * 100).toFixed(1))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalInCrm,
|
||||
pendingCount,
|
||||
contactedCount,
|
||||
convertedCount,
|
||||
unresponsiveCount,
|
||||
conversionRate,
|
||||
totalRevenueInCrm: aggregateStats._sum.totalSpent || 0,
|
||||
},
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
customers: customers.map((c) => ({
|
||||
id: c.id,
|
||||
channel: c.channel,
|
||||
shop: c.shop,
|
||||
identifier: c.identifier,
|
||||
displayName: c.displayName,
|
||||
location: c.location,
|
||||
province: c.province,
|
||||
district: c.district,
|
||||
orderCount: c.orderCount,
|
||||
totalSpent: c.totalSpent,
|
||||
avgIntervalDays: c.avgIntervalDays,
|
||||
firstOrderDate: c.firstOrderDate,
|
||||
lastOrderDate: c.lastOrderDate,
|
||||
predictedNextOrderDate: c.predictedNextOrderDate,
|
||||
topProducts: c.topProducts,
|
||||
crm: c.crm
|
||||
? {
|
||||
id: c.crm.id,
|
||||
status: c.crm.status,
|
||||
strategyType: c.crm.strategyType,
|
||||
promoCodeSent: c.crm.promoCodeSent,
|
||||
discountOffered: c.crm.discountOffered,
|
||||
lastContactedAt: c.crm.lastContactedAt,
|
||||
nextFollowUpDate: c.crm.nextFollowUpDate,
|
||||
notes: c.crm.notes,
|
||||
assignedTo: c.crm.assignedTo,
|
||||
activities: c.crm.activities,
|
||||
updatedAt: c.crm.updatedAt,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to list CRM customers" };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 3.1 Get Single CRM Customer Detail with Order History ─────────────────
|
||||
.get("/customers/:id", async ({ params, set }) => {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const cust = await prisma.marketplaceCustomer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
crm: {
|
||||
include: {
|
||||
activities: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!cust) {
|
||||
set.status = 404;
|
||||
return { error: "Customer not found" };
|
||||
}
|
||||
|
||||
// Fetch order history for this customer based on channel & identifier
|
||||
let orders: any[] = [];
|
||||
const channel = cust.channel.toLowerCase();
|
||||
const identifier = cust.identifier;
|
||||
|
||||
if (channel === "tiktok") {
|
||||
const dbOrders = await prisma.tiktokOrder.findMany({
|
||||
where: {
|
||||
buyerUsername: identifier,
|
||||
},
|
||||
orderBy: { createdTime: "desc" },
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
orders = dbOrders.map((o) => ({
|
||||
orderId: o.orderId,
|
||||
channel: "tiktok",
|
||||
shop: o.shop,
|
||||
date: o.createdTime,
|
||||
status: o.status,
|
||||
amount: Number(o.orderAmount) || 0,
|
||||
shippingFee: Number(o.shippingFee) || 0,
|
||||
trackingId: o.trackingId,
|
||||
deliveryOption: o.deliveryOption,
|
||||
items: o.items.map((it) => ({
|
||||
name: it.productName,
|
||||
variation: it.variation,
|
||||
quantity: it.quantity,
|
||||
price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0,
|
||||
})),
|
||||
}));
|
||||
} else if (channel === "shopee") {
|
||||
const dbOrders = await prisma.shopeeOrder.findMany({
|
||||
where: {
|
||||
buyerUsername: identifier,
|
||||
},
|
||||
orderBy: { orderCreatedAt: "desc" },
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
orders = dbOrders.map((o) => ({
|
||||
orderId: o.orderId,
|
||||
channel: "shopee",
|
||||
shop: o.shop,
|
||||
date: o.orderCreatedAt,
|
||||
status: o.status,
|
||||
amount: Number(o.buyerPaidAmount || o.totalAmount) || 0,
|
||||
shippingFee: Number(o.shippingFee) || 0,
|
||||
trackingId: o.trackingNo,
|
||||
items: o.items.map((it) => ({
|
||||
name: it.productName,
|
||||
variation: it.variantName,
|
||||
quantity: it.quantity,
|
||||
price: Number(it.dealPrice || it.originalPrice) || 0,
|
||||
})),
|
||||
}));
|
||||
} else if (channel === "lazada") {
|
||||
const parts = identifier.split("_");
|
||||
const namePart = parts[0];
|
||||
const postCodePart = parts[1] || "";
|
||||
const where: any = {
|
||||
customerName: { equals: namePart, mode: "insensitive" },
|
||||
};
|
||||
if (postCodePart) {
|
||||
where.shippingPostCode = postCodePart;
|
||||
}
|
||||
const dbOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
orderBy: { createTime: "desc" },
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
orders = dbOrders.map((o) => ({
|
||||
orderId: o.orderNumber,
|
||||
channel: "lazada",
|
||||
shop: o.shop,
|
||||
date: o.createTime,
|
||||
status: o.status || "Completed",
|
||||
amount: Number(o.orderAmount) || 0,
|
||||
shippingFee: Number(o.shippingFee) || 0,
|
||||
trackingId: o.trackingCode,
|
||||
items: o.items.map((it) => ({
|
||||
name: it.itemName,
|
||||
variation: it.variation,
|
||||
quantity: 1,
|
||||
price: Number(it.paidPrice || it.unitPrice) || 0,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
customer: cust,
|
||||
orders,
|
||||
};
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to fetch customer detail" };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 4. Update CRM Status & Notes ──────────────────────────────────────────
|
||||
.patch(
|
||||
"/customers/:id",
|
||||
async ({ params, body, set }) => {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const {
|
||||
status,
|
||||
strategyType,
|
||||
notes,
|
||||
promoCodeSent,
|
||||
discountOffered,
|
||||
nextFollowUpDate,
|
||||
assignedTo,
|
||||
activityAction,
|
||||
activityContent,
|
||||
author,
|
||||
} = body as {
|
||||
status?: string;
|
||||
strategyType?: string;
|
||||
notes?: string;
|
||||
promoCodeSent?: string;
|
||||
discountOffered?: string;
|
||||
nextFollowUpDate?: string | null;
|
||||
assignedTo?: string;
|
||||
activityAction?: string;
|
||||
activityContent?: string;
|
||||
author?: string;
|
||||
};
|
||||
|
||||
// Find customer
|
||||
const cust = await prisma.marketplaceCustomer.findUnique({
|
||||
where: { id },
|
||||
include: { crm: true },
|
||||
});
|
||||
|
||||
if (!cust) {
|
||||
set.status = 404;
|
||||
return { error: "Marketplace customer not found" };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (status) {
|
||||
updateData.status = status;
|
||||
if (status === "contacted" || status === "converted") {
|
||||
updateData.lastContactedAt = new Date();
|
||||
}
|
||||
}
|
||||
if (strategyType) updateData.strategyType = strategyType;
|
||||
if (typeof notes !== "undefined") updateData.notes = notes;
|
||||
if (typeof promoCodeSent !== "undefined") updateData.promoCodeSent = promoCodeSent;
|
||||
if (typeof discountOffered !== "undefined") updateData.discountOffered = discountOffered;
|
||||
if (typeof assignedTo !== "undefined") updateData.assignedTo = assignedTo;
|
||||
if (typeof nextFollowUpDate !== "undefined") {
|
||||
updateData.nextFollowUpDate = nextFollowUpDate ? new Date(nextFollowUpDate) : null;
|
||||
}
|
||||
|
||||
let crm = cust.crm;
|
||||
|
||||
if (!crm) {
|
||||
crm = await prisma.marketplaceCustomerCrm.create({
|
||||
data: {
|
||||
customerId: cust.id,
|
||||
status: status || "pending",
|
||||
strategyType: strategyType || "due_restock",
|
||||
...updateData,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
crm = await prisma.marketplaceCustomerCrm.update({
|
||||
where: { id: crm.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Add Activity Log if supplied or if status changed
|
||||
if (activityAction && activityContent) {
|
||||
await prisma.customerCrmActivity.create({
|
||||
data: {
|
||||
crmId: crm.id,
|
||||
action: activityAction,
|
||||
content: activityContent,
|
||||
author: author || "Admin",
|
||||
},
|
||||
});
|
||||
} else if (status && status !== cust.crm?.status) {
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "⏳ รอดำเนินการ",
|
||||
contacted: "💬 ส่งโปรแล้ว",
|
||||
converted: "✅ ซื้อซ้ำสำเร็จ",
|
||||
unresponsive: "❌ ลูกค้าไม่ตอบ/ไม่สนใจ",
|
||||
do_not_disturb: "⛔ ขอไม่รับการติดต่อ",
|
||||
};
|
||||
await prisma.customerCrmActivity.create({
|
||||
data: {
|
||||
crmId: crm.id,
|
||||
action: "STATUS_CHANGE",
|
||||
content: `เปลี่ยนสถานะเป็น: ${statusLabels[status] || status}`,
|
||||
author: author || "Admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
customer: {
|
||||
...cust,
|
||||
crm,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to update CRM status" };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
status: t.Optional(t.String()),
|
||||
strategyType: t.Optional(t.String()),
|
||||
notes: t.Optional(t.String()),
|
||||
promoCodeSent: t.Optional(t.String()),
|
||||
discountOffered: t.Optional(t.String()),
|
||||
nextFollowUpDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
assignedTo: t.Optional(t.String()),
|
||||
activityAction: t.Optional(t.String()),
|
||||
activityContent: t.Optional(t.String()),
|
||||
author: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ── 5. Add Custom Activity Note ───────────────────────────────────────────
|
||||
.post(
|
||||
"/customers/:id/activities",
|
||||
async ({ params, body, set }) => {
|
||||
try {
|
||||
const customerId = parseInt(params.id);
|
||||
const { action, content, author } = body as {
|
||||
action: string;
|
||||
content: string;
|
||||
author?: string;
|
||||
};
|
||||
|
||||
const crm = await prisma.marketplaceCustomerCrm.findUnique({
|
||||
where: { customerId },
|
||||
});
|
||||
|
||||
if (!crm) {
|
||||
set.status = 404;
|
||||
return { error: "Customer CRM record not found" };
|
||||
}
|
||||
|
||||
const activity = await prisma.customerCrmActivity.create({
|
||||
data: {
|
||||
crmId: crm.id,
|
||||
action: action || "NOTE",
|
||||
content,
|
||||
author: author || "Admin",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, activity };
|
||||
} catch (err: any) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to add activity" };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
action: t.String(),
|
||||
content: t.String(),
|
||||
author: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
);
|
||||
Reference in new issue
Block a user