671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
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()),
|
|
}),
|
|
}
|
|
);
|