Files
homaroma/apps/backend/src/routes/tools.ts
T
2026-09-06 14:39:13 +07:00

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,
};
});