This commit is contained in:
Your Name committed 2026-09-14 11:46:07 +07:00
1 parent d5ea45a536
commit afd6904c48
20 files changed
+5435 -51

No files matched your search

@@ -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,
};
+670
View File
@@ -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()),
}),
}
);