510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
|
|
export const toolsRoute = new Elysia({ prefix: "/tools" })
|
|
|
|
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
|
|
.get("/marketplace-products/master-variants", async () => {
|
|
const variants = await prisma.productVariant.findMany({
|
|
include: {
|
|
product: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
category: { select: { name: true } },
|
|
brand: { select: { name: true } },
|
|
},
|
|
},
|
|
bomItems: {
|
|
include: {
|
|
rawMaterial: {
|
|
select: { pricePerUnit: true, name: true, unit: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ product: { name: "asc" } },
|
|
{ sku: "asc" },
|
|
],
|
|
});
|
|
|
|
const result = variants.map((v) => {
|
|
// Calculate unit BOM cost
|
|
const bomCost = (v.bomItems || []).reduce((sum, b) => {
|
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
|
}, 0);
|
|
|
|
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
|
const displayName = `${v.product?.name || "Unnamed Product"} ${optionsStr ? `(${optionsStr})` : ""} ${v.sku ? `[${v.sku}]` : ""}`.trim();
|
|
|
|
return {
|
|
id: v.id,
|
|
sku: v.sku || "",
|
|
barcode: v.barcode || "",
|
|
price: v.price || 0,
|
|
stock: v.stock || 0,
|
|
optionsStr,
|
|
productName: v.product?.name || "",
|
|
categoryName: v.product?.category?.name || "",
|
|
brandName: v.product?.brand?.name || "",
|
|
bomCost,
|
|
displayName,
|
|
};
|
|
});
|
|
|
|
return { variants: result };
|
|
})
|
|
|
|
// ── 2. POST Sync & Extract Marketplace Products ──────────────────────────────
|
|
.post("/marketplace-products/sync", async () => {
|
|
// A. Fetch all existing variants for auto-matching
|
|
const masterVariants = await prisma.productVariant.findMany({
|
|
select: { id: true, sku: true, barcode: true },
|
|
});
|
|
|
|
const skuToVariantId = new Map<string, number>();
|
|
for (const v of masterVariants) {
|
|
if (v.sku?.trim()) skuToVariantId.set(v.sku.trim().toLowerCase(), v.id);
|
|
if (v.barcode?.trim()) skuToVariantId.set(v.barcode.trim().toLowerCase(), v.id);
|
|
}
|
|
|
|
// Existing marketplace products map to preserve manual mappings
|
|
const existingMp = await prisma.marketplaceProduct.findMany({
|
|
select: {
|
|
id: true,
|
|
channel: true,
|
|
shop: true,
|
|
productName: true,
|
|
variation: true,
|
|
marketplaceSku: true,
|
|
masterVariantId: true,
|
|
},
|
|
});
|
|
|
|
const existingMap = new Map<string, { id: number; masterVariantId: number | null }>();
|
|
for (const ep of existingMp) {
|
|
const key = `${ep.channel}:::${ep.shop}:::${ep.productName}:::${ep.variation}:::${ep.marketplaceSku}`;
|
|
existingMap.set(key, { id: ep.id, masterVariantId: ep.masterVariantId });
|
|
}
|
|
|
|
// In-memory aggregation map
|
|
interface AggregatedItem {
|
|
channel: string;
|
|
shop: string;
|
|
marketplaceSku: string;
|
|
productName: string;
|
|
variation: string;
|
|
totalSoldQty: number;
|
|
totalRevenue: number;
|
|
firstSoldAt: string | null;
|
|
lastSoldAt: string | null;
|
|
}
|
|
|
|
const aggregated = new Map<string, AggregatedItem>();
|
|
|
|
const updateAgg = (
|
|
channel: string,
|
|
shop: string,
|
|
sku: string,
|
|
name: string,
|
|
variation: string,
|
|
qty: number,
|
|
rev: number,
|
|
time: string | null
|
|
) => {
|
|
const cleanSku = (sku || "").trim();
|
|
const cleanName = (name || "").trim() || "ไม่ระบุชื่อสินค้า";
|
|
const cleanVar = (variation || "").trim();
|
|
const cleanShop = (shop || "").trim() || "hom-aroma";
|
|
|
|
const key = `${channel}:::${cleanShop}:::${cleanName}:::${cleanVar}:::${cleanSku}`;
|
|
let item = aggregated.get(key);
|
|
|
|
if (!item) {
|
|
item = {
|
|
channel,
|
|
shop: cleanShop,
|
|
marketplaceSku: cleanSku,
|
|
productName: cleanName,
|
|
variation: cleanVar,
|
|
totalSoldQty: 0,
|
|
totalRevenue: 0,
|
|
firstSoldAt: time,
|
|
lastSoldAt: time,
|
|
};
|
|
aggregated.set(key, item);
|
|
}
|
|
|
|
item.totalSoldQty += qty;
|
|
item.totalRevenue += rev;
|
|
|
|
if (time) {
|
|
if (!item.firstSoldAt || time < item.firstSoldAt) item.firstSoldAt = time;
|
|
if (!item.lastSoldAt || time > item.lastSoldAt) item.lastSoldAt = time;
|
|
}
|
|
};
|
|
|
|
// 1. Scan TikTok Order Items
|
|
const tiktokItems = await prisma.tiktokOrderItem.findMany({
|
|
include: {
|
|
order: { select: { shop: true, createdTime: true } },
|
|
},
|
|
});
|
|
|
|
for (const it of tiktokItems) {
|
|
const shop = it.order?.shop || "hom-aroma";
|
|
const sku = it.sellerSku || it.skuId || "";
|
|
const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : (it.unitOriginalPrice * (it.quantity || 1));
|
|
updateAgg("tiktok", shop, sku, it.productName, it.variation || "", it.quantity || 1, rev, it.order?.createdTime || null);
|
|
}
|
|
|
|
// 2. Scan Shopee Order Items
|
|
const shopeeItems = await prisma.shopeeOrderItem.findMany({
|
|
include: {
|
|
order: { select: { shop: true, orderCreatedAt: true } },
|
|
},
|
|
});
|
|
|
|
for (const it of shopeeItems) {
|
|
const shop = it.order?.shop || "hom-aroma";
|
|
const sku = it.skuRef || it.parentSku || "";
|
|
const rev = it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * (it.quantity || 1));
|
|
updateAgg("shopee", shop, sku, it.productName, it.variantName || "", it.quantity || 1, rev, it.order?.orderCreatedAt || null);
|
|
}
|
|
|
|
// 3. Scan Lazada Order Items
|
|
const lazadaItems = await prisma.lazadaOrderItem.findMany({
|
|
include: {
|
|
order: { select: { shop: true, createTime: true } },
|
|
},
|
|
});
|
|
|
|
for (const it of lazadaItems) {
|
|
const shop = it.order?.shop || "hom-aroma";
|
|
const sku = it.sellerSku || it.lazadaSku || "";
|
|
const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice;
|
|
updateAgg("lazada", shop, sku, it.itemName, it.variation || "", 1, rev, it.order?.createTime || null);
|
|
}
|
|
|
|
// Upsert into Database
|
|
let syncedCount = 0;
|
|
let matchedCount = 0;
|
|
|
|
const itemsToProcess = Array.from(aggregated.values());
|
|
const batchSize = 100;
|
|
|
|
for (let i = 0; i < itemsToProcess.length; i += batchSize) {
|
|
const chunk = itemsToProcess.slice(i, i + batchSize);
|
|
await prisma.$transaction(
|
|
chunk.map((item) => {
|
|
const key = `${item.channel}:::${item.shop}:::${item.productName}:::${item.variation}:::${item.marketplaceSku}`;
|
|
const existing = existingMap.get(key);
|
|
|
|
// Determine masterVariantId
|
|
let masterVariantId = existing?.masterVariantId || null;
|
|
|
|
// If unmapped, try auto-matching by SKU
|
|
if (!masterVariantId && item.marketplaceSku) {
|
|
const normalized = item.marketplaceSku.trim().toLowerCase();
|
|
const autoMatchId = skuToVariantId.get(normalized);
|
|
if (autoMatchId) {
|
|
masterVariantId = autoMatchId;
|
|
matchedCount++;
|
|
}
|
|
}
|
|
|
|
const avgPrice = item.totalSoldQty > 0 ? item.totalRevenue / item.totalSoldQty : 0;
|
|
|
|
return prisma.marketplaceProduct.upsert({
|
|
where: {
|
|
channel_shop_productName_variation_marketplaceSku: {
|
|
channel: item.channel,
|
|
shop: item.shop,
|
|
productName: item.productName,
|
|
variation: item.variation,
|
|
marketplaceSku: item.marketplaceSku,
|
|
},
|
|
},
|
|
create: {
|
|
channel: item.channel,
|
|
shop: item.shop,
|
|
marketplaceSku: item.marketplaceSku,
|
|
productName: item.productName,
|
|
variation: item.variation,
|
|
avgPrice,
|
|
totalSoldQty: item.totalSoldQty,
|
|
totalRevenue: item.totalRevenue,
|
|
firstSoldAt: item.firstSoldAt,
|
|
lastSoldAt: item.lastSoldAt,
|
|
masterVariantId,
|
|
},
|
|
update: {
|
|
avgPrice,
|
|
totalSoldQty: item.totalSoldQty,
|
|
totalRevenue: item.totalRevenue,
|
|
firstSoldAt: item.firstSoldAt,
|
|
lastSoldAt: item.lastSoldAt,
|
|
// If already mapped manually, preserve it; otherwise use newly matched
|
|
...(masterVariantId ? { masterVariantId } : {}),
|
|
},
|
|
});
|
|
})
|
|
);
|
|
syncedCount += chunk.length;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
syncedCount,
|
|
matchedCount,
|
|
totalMarketplaceProducts: itemsToProcess.length,
|
|
};
|
|
})
|
|
|
|
// ── 3. GET List of Marketplace Products with Profitability ──────────────────
|
|
.get("/marketplace-products", async ({ query }) => {
|
|
const channel = (query.channel as string) || "all";
|
|
const shop = (query.shop as string) || "all";
|
|
const status = (query.status as string) || "all"; // 'all' | 'mapped' | 'unmapped'
|
|
const search = (query.search as string || "").trim();
|
|
const sortBy = (query.sortBy as string) || "revenue"; // 'revenue' | 'sold' | 'name' | 'recent'
|
|
const page = parseInt(query.page as string || "1", 10);
|
|
const limit = parseInt(query.limit as string || "50", 10);
|
|
|
|
// Build Where Clause
|
|
const where: any = {};
|
|
|
|
if (channel !== "all") where.channel = channel;
|
|
if (shop !== "all") where.shop = shop;
|
|
if (status === "mapped") where.masterVariantId = { not: null };
|
|
if (status === "unmapped") where.masterVariantId = null;
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ productName: { contains: search, mode: "insensitive" } },
|
|
{ marketplaceSku: { contains: search, mode: "insensitive" } },
|
|
{ variation: { contains: search, mode: "insensitive" } },
|
|
{ masterVariant: { sku: { contains: search, mode: "insensitive" } } },
|
|
{ masterVariant: { product: { name: { contains: search, mode: "insensitive" } } } },
|
|
];
|
|
}
|
|
|
|
// Build OrderBy Clause
|
|
let orderBy: any = [{ totalRevenue: "desc" }];
|
|
if (sortBy === "sold") orderBy = [{ totalSoldQty: "desc" }];
|
|
if (sortBy === "name") orderBy = [{ productName: "asc" }];
|
|
if (sortBy === "recent") orderBy = [{ lastSoldAt: "desc" }];
|
|
|
|
// Fetch Global KPIs across all items (without pagination limits)
|
|
const allItems = await prisma.marketplaceProduct.findMany({
|
|
where: channel !== "all" || shop !== "all" ? { ...(channel !== "all" ? { channel } : {}), ...(shop !== "all" ? { shop } : {}) } : {},
|
|
include: {
|
|
masterVariant: {
|
|
include: {
|
|
bomItems: {
|
|
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let totalSkus = allItems.length;
|
|
let mappedSkus = 0;
|
|
let totalSoldQty = 0;
|
|
let totalRevenue = 0;
|
|
let totalEstCost = 0;
|
|
let totalEstProfit = 0;
|
|
|
|
for (const item of allItems) {
|
|
totalSoldQty += item.totalSoldQty;
|
|
totalRevenue += item.totalRevenue;
|
|
|
|
if (item.masterVariantId && item.masterVariant) {
|
|
mappedSkus++;
|
|
const unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
|
}, 0);
|
|
|
|
const cost = unitBomCost * item.totalSoldQty;
|
|
totalEstCost += cost;
|
|
totalEstProfit += (item.totalRevenue - cost);
|
|
}
|
|
}
|
|
|
|
const unmappedSkus = totalSkus - mappedSkus;
|
|
const mappedPercent = totalSkus > 0 ? (mappedSkus / totalSkus) * 100 : 0;
|
|
const grossProfitMargin = totalRevenue > 0 ? (totalEstProfit / totalRevenue) * 100 : 0;
|
|
|
|
// Fetch Paginated Items
|
|
const [items, totalCount] = await Promise.all([
|
|
prisma.marketplaceProduct.findMany({
|
|
where,
|
|
orderBy,
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
include: {
|
|
masterVariant: {
|
|
include: {
|
|
product: {
|
|
select: { id: true, name: true, category: { select: { name: true } } },
|
|
},
|
|
bomItems: {
|
|
include: {
|
|
rawMaterial: { select: { name: true, pricePerUnit: true, unit: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
prisma.marketplaceProduct.count({ where }),
|
|
]);
|
|
|
|
// Format output with profitability details
|
|
const formattedItems = items.map((item) => {
|
|
let unitBomCost = 0;
|
|
let totalCost = 0;
|
|
let profit = 0;
|
|
let marginPercent = 0;
|
|
|
|
if (item.masterVariant) {
|
|
unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
|
}, 0);
|
|
totalCost = unitBomCost * item.totalSoldQty;
|
|
profit = item.totalRevenue - totalCost;
|
|
marginPercent = item.totalRevenue > 0 ? (profit / item.totalRevenue) * 100 : 0;
|
|
}
|
|
|
|
return {
|
|
id: item.id,
|
|
channel: item.channel,
|
|
shop: item.shop,
|
|
marketplaceSku: item.marketplaceSku,
|
|
productName: item.productName,
|
|
variation: item.variation,
|
|
avgPrice: item.avgPrice,
|
|
totalSoldQty: item.totalSoldQty,
|
|
totalRevenue: item.totalRevenue,
|
|
firstSoldAt: item.firstSoldAt,
|
|
lastSoldAt: item.lastSoldAt,
|
|
masterVariantId: item.masterVariantId,
|
|
masterVariant: item.masterVariant
|
|
? {
|
|
id: item.masterVariant.id,
|
|
sku: item.masterVariant.sku,
|
|
barcode: item.masterVariant.barcode,
|
|
productName: item.masterVariant.product?.name,
|
|
categoryName: item.masterVariant.product?.category?.name,
|
|
option1: item.masterVariant.option1,
|
|
option2: item.masterVariant.option2,
|
|
option3: item.masterVariant.option3,
|
|
unitBomCost,
|
|
}
|
|
: null,
|
|
profitability: item.masterVariant
|
|
? {
|
|
unitBomCost,
|
|
totalCost,
|
|
profit,
|
|
marginPercent,
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
kpi: {
|
|
totalSkus,
|
|
mappedSkus,
|
|
unmappedSkus,
|
|
mappedPercent,
|
|
totalSoldQty,
|
|
totalRevenue,
|
|
totalEstCost,
|
|
totalEstProfit,
|
|
grossProfitMargin,
|
|
},
|
|
pagination: {
|
|
total: totalCount,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(totalCount / limit) || 1,
|
|
},
|
|
items: formattedItems,
|
|
};
|
|
})
|
|
|
|
// ── 4. POST Link / Unlink Master Variant ─────────────────────────────────────
|
|
.post(
|
|
"/marketplace-products/map",
|
|
async ({ body }: { body: { marketplaceProductId: number; masterVariantId: number | null } }) => {
|
|
const { marketplaceProductId, masterVariantId } = body;
|
|
|
|
const updated = await prisma.marketplaceProduct.update({
|
|
where: { id: marketplaceProductId },
|
|
data: { masterVariantId },
|
|
include: {
|
|
masterVariant: {
|
|
include: {
|
|
product: { select: { id: true, name: true } },
|
|
bomItems: {
|
|
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return { success: true, item: updated };
|
|
},
|
|
{
|
|
body: t.Object({
|
|
marketplaceProductId: t.Number(),
|
|
masterVariantId: t.Nullable(t.Number()),
|
|
}),
|
|
}
|
|
)
|
|
|
|
// ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
|
|
.post("/marketplace-products/bulk-auto-match", async () => {
|
|
const masterVariants = await prisma.productVariant.findMany({
|
|
select: { id: true, sku: true, barcode: true },
|
|
});
|
|
|
|
const skuMap = new Map<string, number>();
|
|
for (const v of masterVariants) {
|
|
if (v.sku?.trim()) skuMap.set(v.sku.trim().toLowerCase(), v.id);
|
|
if (v.barcode?.trim()) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
|
|
}
|
|
|
|
const unmapped = await prisma.marketplaceProduct.findMany({
|
|
where: { masterVariantId: null, marketplaceSku: { not: "" } },
|
|
});
|
|
|
|
let matched = 0;
|
|
for (const item of unmapped) {
|
|
const norm = item.marketplaceSku.trim().toLowerCase();
|
|
const variantId = skuMap.get(norm);
|
|
if (variantId) {
|
|
await prisma.marketplaceProduct.update({
|
|
where: { id: item.id },
|
|
data: { masterVariantId: variantId },
|
|
});
|
|
matched++;
|
|
}
|
|
}
|
|
|
|
const remainingUnmapped = await prisma.marketplaceProduct.count({
|
|
where: { masterVariantId: null },
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
matched,
|
|
remainingUnmapped,
|
|
};
|
|
});
|