261 lines
9.9 KiB
JavaScript
261 lines
9.9 KiB
JavaScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
|
|
.post("/import/income", async ({ body, set }) => {
|
|
try {
|
|
const { data } = body;
|
|
// ── Debug logging ────────────────────────────────────────────────────
|
|
console.log(`[TikTok/Income] Received ${data.length} rows`);
|
|
if (data.length > 0) {
|
|
console.log(`[TikTok/Income] First row keys:`, Object.keys(data[0]));
|
|
console.log(`[TikTok/Income] First row id:`, data[0].id);
|
|
console.log(`[TikTok/Income] First row sample:`, JSON.stringify(data[0]).slice(0, 300));
|
|
}
|
|
// Check for duplicate/missing IDs
|
|
const ids = data.map((r) => r.id);
|
|
const uniqueIds = new Set(ids);
|
|
console.log(`[TikTok/Income] Total IDs: ${ids.length}, Unique IDs: ${uniqueIds.size}`);
|
|
if (uniqueIds.size < ids.length) {
|
|
console.warn(`[TikTok/Income] ⚠️ Duplicate IDs detected!`);
|
|
}
|
|
// ── Chunked upsert (avoid transaction timeout on large files) ────────
|
|
const CHUNK_SIZE = 50;
|
|
let inserted = 0;
|
|
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
|
const chunk = data.slice(i, i + CHUNK_SIZE);
|
|
await prisma.$transaction(chunk.map((row) => prisma.tiktokIncome.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
type: row.type,
|
|
orderCreatedTime: row.orderCreatedTime,
|
|
orderSettledTime: row.orderSettledTime,
|
|
currency: row.currency,
|
|
totalSettlementAmount: row.totalSettlementAmount,
|
|
totalRevenue: row.totalRevenue,
|
|
totalFees: row.totalFees,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
type: row.type,
|
|
orderCreatedTime: row.orderCreatedTime,
|
|
orderSettledTime: row.orderSettledTime,
|
|
currency: row.currency,
|
|
totalSettlementAmount: row.totalSettlementAmount,
|
|
totalRevenue: row.totalRevenue,
|
|
totalFees: row.totalFees,
|
|
rawData: row.rawData,
|
|
},
|
|
})), { timeout: 30000 });
|
|
inserted += chunk.length;
|
|
console.log(`[TikTok/Income] Progress: ${inserted}/${data.length}`);
|
|
}
|
|
console.log(`[TikTok/Income] ✅ Done — ${inserted} records upserted`);
|
|
return { success: true, message: `Imported ${inserted} income records.` };
|
|
}
|
|
catch (error) {
|
|
console.error(`[TikTok/Income] ❌ Error:`, error.message);
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
})
|
|
.post("/import/product", async ({ body, set }) => {
|
|
try {
|
|
const { data } = body;
|
|
await prisma.$transaction(data.map((row) => prisma.tiktokProduct.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
productName: row.productName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
itemsSold: row.itemsSold,
|
|
orders: row.orders,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
productName: row.productName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
itemsSold: row.itemsSold,
|
|
orders: row.orders,
|
|
rawData: row.rawData,
|
|
},
|
|
})));
|
|
return { success: true, message: `Imported ${data.length} product records.` };
|
|
}
|
|
catch (error) {
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
})
|
|
.post("/import/product-sku", async ({ body, set }) => {
|
|
try {
|
|
const { data } = body;
|
|
await prisma.$transaction(data.map((row) => prisma.tiktokProductSku.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
productId: row.productId,
|
|
skuName: row.skuName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
skuOrders: row.skuOrders,
|
|
itemsSold: row.itemsSold,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
productId: row.productId,
|
|
skuName: row.skuName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
skuOrders: row.skuOrders,
|
|
itemsSold: row.itemsSold,
|
|
rawData: row.rawData,
|
|
},
|
|
})));
|
|
return { success: true, message: `Imported ${data.length} SKU records.` };
|
|
}
|
|
catch (error) {
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
})
|
|
// ── GET endpoints ────────────────────────────────────────────────────────────
|
|
.get("/income", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokIncome.findMany({
|
|
orderBy: { orderCreatedTime: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokIncome.count(),
|
|
]);
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/product", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokProduct.findMany({
|
|
orderBy: { gmv: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokProduct.count(),
|
|
]);
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/product-sku", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokProductSku.findMany({
|
|
orderBy: { gmv: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokProductSku.count(),
|
|
]);
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/summary", async () => {
|
|
const [incomeCount, productCount, skuCount, incomeAgg, productAgg] = await Promise.all([
|
|
prisma.tiktokIncome.count(),
|
|
prisma.tiktokProduct.count(),
|
|
prisma.tiktokProductSku.count(),
|
|
prisma.tiktokIncome.aggregate({ _sum: { totalRevenue: true, totalSettlementAmount: true } }),
|
|
prisma.tiktokProduct.aggregate({ _sum: { gmv: true, itemsSold: true, orders: true } }),
|
|
]);
|
|
return {
|
|
incomeCount,
|
|
productCount,
|
|
skuCount,
|
|
totalRevenue: incomeAgg._sum.totalRevenue ?? 0,
|
|
totalSettlement: incomeAgg._sum.totalSettlementAmount ?? 0,
|
|
totalGmv: productAgg._sum.gmv ?? 0,
|
|
totalItemsSold: productAgg._sum.itemsSold ?? 0,
|
|
totalOrders: productAgg._sum.orders ?? 0,
|
|
};
|
|
})
|
|
.get("/analysis/sku", async () => {
|
|
// 1. Top 10 by GMV
|
|
const topGmv = await prisma.$queryRaw `
|
|
WITH SkuStats AS (
|
|
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
|
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
|
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
|
FROM "TiktokProductSku"
|
|
)
|
|
SELECT * FROM SkuStats ORDER BY "gmv" DESC LIMIT 10
|
|
`;
|
|
// 2. Top 10 by Volume (itemsSold)
|
|
const topVolume = await prisma.$queryRaw `
|
|
WITH SkuStats AS (
|
|
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
|
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
|
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
|
FROM "TiktokProductSku"
|
|
)
|
|
SELECT * FROM SkuStats ORDER BY "itemsSold" DESC LIMIT 10
|
|
`;
|
|
// 3. Scent/Variant Grouping
|
|
const scentAnalysis = await prisma.$queryRaw `
|
|
WITH SplitNames AS (
|
|
SELECT
|
|
"id",
|
|
"gmv",
|
|
"itemsSold",
|
|
"skuOrders",
|
|
TRIM(SPLIT_PART("skuName", ':', 2)) AS "scentName"
|
|
FROM "TiktokProductSku"
|
|
WHERE "skuName" LIKE '%:%'
|
|
)
|
|
SELECT
|
|
"scentName",
|
|
SUM("gmv") AS "totalGmv",
|
|
SUM("itemsSold") AS "totalItems",
|
|
SUM("skuOrders") AS "totalOrders",
|
|
COUNT("id") AS "skuCount"
|
|
FROM SplitNames
|
|
GROUP BY "scentName"
|
|
ORDER BY "totalGmv" DESC
|
|
LIMIT 15
|
|
`;
|
|
// 4. Overall Summary (Average AOV, Total GMV)
|
|
const summaryList = await prisma.$queryRaw `
|
|
SELECT
|
|
SUM("gmv") AS "totalGmv",
|
|
SUM("itemsSold") AS "totalItems",
|
|
SUM("skuOrders") AS "totalOrders",
|
|
CASE WHEN SUM("skuOrders") > 0 THEN SUM("gmv") / SUM("skuOrders") ELSE 0 END AS "avgAov",
|
|
CASE WHEN SUM("itemsSold") > 0 THEN SUM("gmv") / SUM("itemsSold") ELSE 0 END AS "avgPrice"
|
|
FROM "TiktokProductSku"
|
|
`;
|
|
// Handle BigInt conversion since Prisma $queryRaw returns BigInts for COUNT/SUM in Postgres sometimes.
|
|
const serialize = (data) => JSON.parse(JSON.stringify(data, (key, value) => (typeof value === "bigint" ? Number(value) : value)));
|
|
return serialize({
|
|
summary: summaryList[0] || null,
|
|
topGmv,
|
|
topVolume,
|
|
scentAnalysis
|
|
});
|
|
});
|
|
//# sourceMappingURL=tiktok.js.map
|