Initial commit for homaroma
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "iRelax API — Elysia.js + Prisma 7",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.2.2",
|
||||
"@elysiajs/jwt": "^1.4.1",
|
||||
"@elysiajs/static": "^1.4.7",
|
||||
"@elysiajs/swagger": "^1.2.1",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.0",
|
||||
"elysia": "^1.2.25",
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.6.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.cjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Prisma 7 Config
|
||||
// Note: prisma CLI uses jiti (Node.js) to load this file, NOT Bun
|
||||
// So we must import dotenv explicitly here to load DATABASE_URL
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "bun prisma/seed.cjs",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
ผลิตภัณฑ์ Aroma Oil กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น
|
||||
@@ -0,0 +1 @@
|
||||
ผลิตภัณฑ์ Car Diffuser กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น
|
||||
@@ -0,0 +1 @@
|
||||
ผลิตภัณฑ์ Oil Ozone กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น
|
||||
@@ -0,0 +1 @@
|
||||
ผลิตภัณฑ์ Reed Diffuser กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น
|
||||
@@ -0,0 +1 @@
|
||||
ผลิตภัณฑ์ Room Spray กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น
|
||||
@@ -0,0 +1,4 @@
|
||||
รายละเอียดฉบับเต็มของ Aroma Oil กลิ่น {{scent}}
|
||||
- ส่วนประกอบนำเข้าคุณภาพสูง
|
||||
- กระจายความหอมได้ยาวนานในห้อง
|
||||
- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว
|
||||
@@ -0,0 +1,4 @@
|
||||
รายละเอียดฉบับเต็มของ Car Diffuser กลิ่น {{scent}}
|
||||
- ส่วนประกอบนำเข้าคุณภาพสูง
|
||||
- กระจายความหอมได้ยาวนานในห้อง
|
||||
- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว
|
||||
@@ -0,0 +1,4 @@
|
||||
รายละเอียดฉบับเต็มของ Oil Ozone กลิ่น {{scent}}
|
||||
- ส่วนประกอบนำเข้าคุณภาพสูง
|
||||
- กระจายความหอมได้ยาวนานในห้อง
|
||||
- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว
|
||||
@@ -0,0 +1,4 @@
|
||||
รายละเอียดฉบับเต็มของ Reed Diffuser กลิ่น {{scent}}
|
||||
- ส่วนประกอบนำเข้าคุณภาพสูง
|
||||
- กระจายความหอมได้ยาวนานในห้อง
|
||||
- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว
|
||||
@@ -0,0 +1,4 @@
|
||||
รายละเอียดฉบับเต็มของ Room Spray กลิ่น {{scent}}
|
||||
- ส่วนประกอบนำเข้าคุณภาพสูง
|
||||
- กระจายความหอมได้ยาวนานในห้อง
|
||||
- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว
|
||||
@@ -0,0 +1,94 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Scent" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Scent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DOUBLE PRECISION DEFAULT 0,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"scentId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"productId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductVariant" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"size" TEXT NOT NULL,
|
||||
"price" DOUBLE PRECISION DEFAULT 0,
|
||||
"productId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'ADMIN',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Scent_name_key" ON "Scent"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_scentId_categoryId_key" ON "Product"("scentId", "categoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductVariant_productId_size_key" ON "ProductVariant"("productId", "size");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_scentId_fkey" FOREIGN KEY ("scentId") REFERENCES "Scent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "details" TEXT;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Hero" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Hero_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StoreSettings" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 35,
|
||||
"bankName" TEXT NOT NULL,
|
||||
"bankAccountNumber" TEXT NOT NULL,
|
||||
"bankAccountName" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "StoreSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Order" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"customerPhone" TEXT NOT NULL,
|
||||
"customerEmail" TEXT NOT NULL,
|
||||
"shippingAddress" TEXT NOT NULL,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 35,
|
||||
"totalAmount" DOUBLE PRECISION NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
|
||||
"paymentSlipUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrderItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderId" INTEGER NOT NULL,
|
||||
"productId" INTEGER NOT NULL,
|
||||
"variantId" INTEGER NOT NULL,
|
||||
"productName" TEXT NOT NULL,
|
||||
"variantSize" TEXT NOT NULL,
|
||||
"productImage" TEXT,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"priceAtPurchase" DOUBLE PRECISION NOT NULL,
|
||||
|
||||
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `description` to the `Hero` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `tagline` to the `Hero` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `title` to the `Hero` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Hero" ADD COLUMN "buttonLink" TEXT NOT NULL DEFAULT '/shop',
|
||||
ADD COLUMN "buttonText" TEXT NOT NULL DEFAULT 'Explore Collection',
|
||||
ADD COLUMN "description" TEXT NOT NULL,
|
||||
ADD COLUMN "tagline" TEXT NOT NULL,
|
||||
ADD COLUMN "title" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrderItem" ALTER COLUMN "variantId" DROP NOT NULL,
|
||||
ALTER COLUMN "variantSize" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreSettings" ALTER COLUMN "id" SET DEFAULT 1,
|
||||
ALTER COLUMN "id" DROP DEFAULT,
|
||||
ALTER COLUMN "bankName" SET DEFAULT 'KASIKORNBANK (KBANK)',
|
||||
ALTER COLUMN "bankAccountNumber" SET DEFAULT '012-3-45678-9',
|
||||
ALTER COLUMN "bankAccountName" SET DEFAULT 'iRelax Aroma Co., Ltd.';
|
||||
DROP SEQUENCE "StoreSettings_id_seq";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Article" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"coverImage" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TiktokIncome" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"orderCreatedTime" TEXT,
|
||||
"orderSettledTime" TEXT,
|
||||
"currency" TEXT NOT NULL,
|
||||
"totalSettlementAmount" DOUBLE PRECISION NOT NULL,
|
||||
"totalRevenue" DOUBLE PRECISION NOT NULL,
|
||||
"totalFees" DOUBLE PRECISION NOT NULL,
|
||||
"rawData" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TiktokIncome_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TiktokProduct" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productName" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"gmv" DOUBLE PRECISION NOT NULL,
|
||||
"itemsSold" INTEGER NOT NULL,
|
||||
"orders" INTEGER NOT NULL,
|
||||
"rawData" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TiktokProduct_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TiktokProductSku" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"skuName" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"gmv" DOUBLE PRECISION NOT NULL,
|
||||
"skuOrders" INTEGER NOT NULL,
|
||||
"itemsSold" INTEGER NOT NULL,
|
||||
"rawData" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TiktokProductSku_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RawMaterial" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"unit" TEXT NOT NULL,
|
||||
"pricePerUnit" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RawMaterial_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BomItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"productVariantId" INTEGER NOT NULL,
|
||||
"rawMaterialId" INTEGER NOT NULL,
|
||||
"quantity" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BomItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShopeeOrder" (
|
||||
"orderId" TEXT NOT NULL,
|
||||
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||
"status" TEXT NOT NULL,
|
||||
"buyerUsername" TEXT,
|
||||
"orderCreatedAt" TEXT,
|
||||
"paymentTime" TEXT,
|
||||
"paymentMethod" TEXT,
|
||||
"trackingNo" TEXT,
|
||||
"totalAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"buyerPaidAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"commission" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"completedAt" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ShopeeOrder_pkey" PRIMARY KEY ("orderId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShopeeOrderItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"parentSku" TEXT,
|
||||
"productName" TEXT NOT NULL,
|
||||
"skuRef" TEXT,
|
||||
"variantName" TEXT,
|
||||
"originalPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"dealPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"totalPayment" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"rawData" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "ShopeeOrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TiktokOrder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||
"status" TEXT NOT NULL,
|
||||
"subStatus" TEXT,
|
||||
"orderType" TEXT,
|
||||
"cancelBy" TEXT,
|
||||
"cancelReason" TEXT,
|
||||
"fulfillmentType" TEXT,
|
||||
"warehouseName" TEXT,
|
||||
"trackingId" TEXT,
|
||||
"deliveryOption" TEXT,
|
||||
"shippingProvider" TEXT,
|
||||
"buyerUsername" TEXT,
|
||||
"paymentMethod" TEXT,
|
||||
"orderAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"refundAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"originalShipping" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"taxes" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"province" TEXT,
|
||||
"district" TEXT,
|
||||
"country" TEXT,
|
||||
"createdTime" TEXT,
|
||||
"paidTime" TEXT,
|
||||
"rtsTime" TEXT,
|
||||
"shippedTime" TEXT,
|
||||
"deliveredTime" TEXT,
|
||||
"cancelledTime" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TiktokOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TiktokOrderItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"skuId" TEXT,
|
||||
"sellerSku" TEXT,
|
||||
"productName" TEXT NOT NULL,
|
||||
"variation" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"skuQtyReturn" INTEGER NOT NULL DEFAULT 0,
|
||||
"unitOriginalPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"subtotalBeforeDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"platformDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"sellerDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"subtotalAfterDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"productCategory" TEXT,
|
||||
"weight" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "TiktokOrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Article_slug_key" ON "Article"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RawMaterial_name_key" ON "RawMaterial"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BomItem_productVariantId_rawMaterialId_key" ON "BomItem"("productVariantId", "rawMaterialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TiktokOrder_orderId_key" ON "TiktokOrder"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TiktokOrder_shop_idx" ON "TiktokOrder"("shop");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TiktokOrder_status_idx" ON "TiktokOrder"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TiktokOrder_createdTime_idx" ON "TiktokOrder"("createdTime");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BomItem" ADD CONSTRAINT "BomItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BomItem" ADD CONSTRAINT "BomItem_rawMaterialId_fkey" FOREIGN KEY ("rawMaterialId") REFERENCES "RawMaterial"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ShopeeOrderItem" ADD CONSTRAINT "ShopeeOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "ShopeeOrder"("orderId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TiktokOrderItem" ADD CONSTRAINT "TiktokOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "TiktokOrder"("orderId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LazadaOrder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderNumber" TEXT NOT NULL,
|
||||
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||
"createTime" TEXT,
|
||||
"updateTime" TEXT,
|
||||
"deliveredDate" TEXT,
|
||||
"customerName" TEXT,
|
||||
"shippingCity" TEXT,
|
||||
"shippingPostCode" TEXT,
|
||||
"shippingRegion" TEXT,
|
||||
"payMethod" TEXT,
|
||||
"status" TEXT,
|
||||
"trackingCode" TEXT,
|
||||
"shippingProvider" TEXT,
|
||||
"orderAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LazadaOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LazadaOrderItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"orderNumber" TEXT NOT NULL,
|
||||
"orderItemId" TEXT NOT NULL,
|
||||
"sellerSku" TEXT,
|
||||
"lazadaSku" TEXT,
|
||||
"itemName" TEXT NOT NULL,
|
||||
"variation" TEXT,
|
||||
"unitPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"paidPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"sellerDiscountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"platformDiscountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"shippingFee" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"status" TEXT,
|
||||
|
||||
CONSTRAINT "LazadaOrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LazadaOrder_orderNumber_key" ON "LazadaOrder"("orderNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LazadaOrder_shop_idx" ON "LazadaOrder"("shop");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LazadaOrder_status_idx" ON "LazadaOrder"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LazadaOrder_createTime_idx" ON "LazadaOrder"("createTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LazadaOrderItem_orderItemId_key" ON "LazadaOrderItem"("orderItemId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LazadaOrderItem" ADD CONSTRAINT "LazadaOrderItem_orderNumber_fkey" FOREIGN KEY ("orderNumber") REFERENCES "LazadaOrder"("orderNumber") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Category" ADD COLUMN "imageUrl" TEXT;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "customerId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"lineUserId" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"pictureUrl" TEXT,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Customer_lineUserId_key" ON "Customer"("lineUserId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "couponCode" TEXT,
|
||||
ADD COLUMN "discountAmount" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Coupon" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"minPurchase" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"maxDiscount" DOUBLE PRECISION,
|
||||
"usageLimit" INTEGER,
|
||||
"usedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,374 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Scent {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
imageUrl String?
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
details String?
|
||||
price Float? @default(0)
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id])
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, categoryId])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id Int @id @default(autoincrement())
|
||||
url String
|
||||
order Int @default(0)
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ProductVariant {
|
||||
id Int @id @default(autoincrement())
|
||||
size String
|
||||
price Float? @default(0)
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
bomItems BomItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([productId, size])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("ADMIN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id Int @id @default(autoincrement())
|
||||
lineUserId String @unique
|
||||
displayName String?
|
||||
pictureUrl String?
|
||||
email String?
|
||||
phone String?
|
||||
orders Order[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Hero {
|
||||
id Int @id @default(autoincrement())
|
||||
tagline String
|
||||
title String
|
||||
description String
|
||||
imageUrl String
|
||||
buttonText String @default("Explore Collection")
|
||||
buttonLink String @default("/shop")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Coupon {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
type String // "PERCENTAGE", "FIXED_AMOUNT", "FREE_SHIPPING"
|
||||
value Float
|
||||
minPurchase Float @default(0)
|
||||
maxDiscount Float?
|
||||
usageLimit Int?
|
||||
usedCount Int @default(0)
|
||||
expiresAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model StoreSettings {
|
||||
id Int @id @default(1)
|
||||
shippingFee Float @default(35)
|
||||
bankName String @default("KASIKORNBANK (KBANK)")
|
||||
bankAccountName String @default("iRelax Aroma Co., Ltd.")
|
||||
bankAccountNumber String @default("012-3-45678-9")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
customerName String
|
||||
customerEmail String
|
||||
customerPhone String
|
||||
shippingAddress String
|
||||
totalAmount Float
|
||||
shippingFee Float @default(35)
|
||||
couponCode String?
|
||||
discountAmount Float @default(0)
|
||||
status String @default("PENDING_PAYMENT")
|
||||
paymentSlipUrl String?
|
||||
|
||||
customerId Int?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
items OrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId Int
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId Int
|
||||
variantId Int?
|
||||
quantity Int
|
||||
priceAtPurchase Float
|
||||
productName String
|
||||
variantSize String?
|
||||
productImage String?
|
||||
}
|
||||
|
||||
model Article {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
coverImage String?
|
||||
content String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TiktokIncome {
|
||||
id String @id // Order/adjustment ID
|
||||
type String
|
||||
orderCreatedTime String?
|
||||
orderSettledTime String?
|
||||
currency String
|
||||
totalSettlementAmount Float
|
||||
totalRevenue Float
|
||||
totalFees Float
|
||||
rawData Json // Stores all other 50+ columns
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TiktokProduct {
|
||||
id String @id // Product ID
|
||||
productName String
|
||||
status String
|
||||
gmv Float
|
||||
itemsSold Int
|
||||
orders Int
|
||||
rawData Json // Stores remaining tab/live/video metrics
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TiktokProductSku {
|
||||
id String @id // SKU ID
|
||||
productId String
|
||||
skuName String
|
||||
status String
|
||||
gmv Float
|
||||
skuOrders Int
|
||||
itemsSold Int
|
||||
rawData Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RawMaterial {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
unit String // e.g., 'kg', 'liter', 'piece'
|
||||
pricePerUnit Float
|
||||
bomItems BomItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BomItem {
|
||||
id Int @id @default(autoincrement())
|
||||
productVariantId Int
|
||||
productVariant ProductVariant @relation(fields: [productVariantId], references: [id], onDelete: Cascade)
|
||||
rawMaterialId Int
|
||||
rawMaterial RawMaterial @relation(fields: [rawMaterialId], references: [id], onDelete: Cascade)
|
||||
quantity Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([productVariantId, rawMaterialId])
|
||||
}
|
||||
|
||||
model ShopeeOrder {
|
||||
orderId String @id
|
||||
shop String @default("hom-aroma")
|
||||
status String
|
||||
buyerUsername String?
|
||||
orderCreatedAt String?
|
||||
paymentTime String?
|
||||
paymentMethod String?
|
||||
trackingNo String?
|
||||
totalAmount Float @default(0)
|
||||
buyerPaidAmount Float @default(0)
|
||||
shippingFee Float @default(0)
|
||||
commission Float @default(0)
|
||||
completedAt String?
|
||||
items ShopeeOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ShopeeOrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String
|
||||
order ShopeeOrder @relation(fields: [orderId], references: [orderId], onDelete: Cascade)
|
||||
parentSku String?
|
||||
productName String
|
||||
skuRef String?
|
||||
variantName String?
|
||||
originalPrice Float @default(0)
|
||||
dealPrice Float @default(0)
|
||||
quantity Int @default(1)
|
||||
totalPayment Float @default(0)
|
||||
rawData Json
|
||||
}
|
||||
|
||||
// ── TikTok Orders ──────────────────────────────────────────────────────────────
|
||||
model TiktokOrder {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String @unique
|
||||
shop String @default("hom-aroma")
|
||||
status String // Order Status (Thai)
|
||||
subStatus String? // Order Substatus
|
||||
orderType String? // Normal / Pre-order
|
||||
cancelBy String?
|
||||
cancelReason String?
|
||||
fulfillmentType String?
|
||||
warehouseName String?
|
||||
trackingId String?
|
||||
deliveryOption String?
|
||||
shippingProvider String?
|
||||
buyerUsername String?
|
||||
paymentMethod String?
|
||||
// Financials (order-level)
|
||||
orderAmount Float @default(0) // Total paid by buyer
|
||||
refundAmount Float @default(0)
|
||||
shippingFee Float @default(0) // Shipping Fee After Discount
|
||||
originalShipping Float @default(0)
|
||||
taxes Float @default(0)
|
||||
// Geography
|
||||
province String?
|
||||
district String?
|
||||
country String?
|
||||
// Timestamps
|
||||
createdTime String?
|
||||
paidTime String?
|
||||
rtsTime String?
|
||||
shippedTime String?
|
||||
deliveredTime String?
|
||||
cancelledTime String?
|
||||
|
||||
items TiktokOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([status])
|
||||
@@index([createdTime])
|
||||
}
|
||||
|
||||
model TiktokOrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String
|
||||
order TiktokOrder @relation(fields: [orderId], references: [orderId], onDelete: Cascade)
|
||||
skuId String?
|
||||
sellerSku String?
|
||||
productName String
|
||||
variation String?
|
||||
quantity Int @default(1)
|
||||
skuQtyReturn Int @default(0)
|
||||
unitOriginalPrice Float @default(0)
|
||||
subtotalBeforeDiscount Float @default(0)
|
||||
platformDiscount Float @default(0)
|
||||
sellerDiscount Float @default(0)
|
||||
subtotalAfterDiscount Float @default(0) // Revenue ระดับ SKU
|
||||
productCategory String?
|
||||
weight Float @default(0)
|
||||
}
|
||||
|
||||
// ── Lazada Orders ──────────────────────────────────────────────────────────────
|
||||
model LazadaOrder {
|
||||
id Int @id @default(autoincrement())
|
||||
orderNumber String @unique
|
||||
shop String @default("hom-aroma")
|
||||
createTime String?
|
||||
updateTime String?
|
||||
deliveredDate String?
|
||||
customerName String?
|
||||
shippingCity String?
|
||||
shippingPostCode String?
|
||||
shippingRegion String?
|
||||
payMethod String?
|
||||
status String? // Based on items or first item
|
||||
trackingCode String?
|
||||
shippingProvider String?
|
||||
// Aggregate from items
|
||||
orderAmount Float @default(0) // sum(paidPrice)
|
||||
shippingFee Float @default(0) // sum(shippingFee)
|
||||
|
||||
items LazadaOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([status])
|
||||
@@index([createTime])
|
||||
}
|
||||
|
||||
model LazadaOrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderNumber String
|
||||
order LazadaOrder @relation(fields: [orderNumber], references: [orderNumber], onDelete: Cascade)
|
||||
orderItemId String @unique
|
||||
sellerSku String?
|
||||
lazadaSku String?
|
||||
itemName String
|
||||
variation String?
|
||||
unitPrice Float @default(0)
|
||||
paidPrice Float @default(0)
|
||||
sellerDiscountTotal Float @default(0)
|
||||
platformDiscountTotal Float @default(0)
|
||||
shippingFee Float @default(0)
|
||||
status String?
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// seed.cjs - Prisma 7 Seed Script
|
||||
// Note: Bun loads .env automatically - no need for dotenv
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const scentNames = [
|
||||
'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์',
|
||||
'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว',
|
||||
'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน',
|
||||
'กุหลาบแดง', 'หมากฝรั่ง', 'ผลไม้รวม', 'มะกรูด', 'แซนด้าวูส', 'ดอกปีบ', 'ดอกบัว',
|
||||
'โอโซน', 'ไทยสปา', 'กล้วย', 'กาแฟ', 'ดอกชมนาด', 'กลิ่นน้ำนม', 'ดอกการะเวก',
|
||||
'ดอกจำปา', 'ดอกพิกุล', 'ดอกพุด', 'ดอกราตรี', 'ดอกลำดวน', 'ทับทิม', 'มะละกอสุก',
|
||||
'ดอกราชาวดี', 'กลิ่นลูกพีช', 'กลิ่นองุ่น'
|
||||
];
|
||||
|
||||
const categoryConfigs = [
|
||||
{
|
||||
name: 'Oil Ozone',
|
||||
sizes: ['30ml', '60ml', '85ml', '120ml'],
|
||||
priceMap: { '30ml': 69, '60ml': 100, '85ml': 120, '120ml': 150 }
|
||||
},
|
||||
{
|
||||
name: 'Aroma Oil',
|
||||
sizes: ['30ml', '60ml', '85ml', '120ml'],
|
||||
priceMap: { '30ml': 90, '60ml': 150, '85ml': 165, '120ml': 225 } // 120ml ปรับเป็น 225 (฿1.875/ml) ให้ถูกกว่า 85ml ต่อ ml
|
||||
},
|
||||
{
|
||||
name: 'Reed Diffuser',
|
||||
sizes: ['60ml', '85ml', '120ml'],
|
||||
priceMap: { '60ml': 120, '85ml': 160, '120ml': 190 }
|
||||
},
|
||||
{
|
||||
name: 'Room Spray',
|
||||
sizes: ['85ml'],
|
||||
priceMap: { '85ml': 150 }
|
||||
},
|
||||
{
|
||||
name: 'Car Diffuser',
|
||||
sizes: ['25ml'],
|
||||
priceMap: { '25ml': 60 }
|
||||
}
|
||||
];
|
||||
|
||||
async function main() {
|
||||
process.stdout.write('🚀 Starting Advanced Seeding (Products & Variants)...\n');
|
||||
|
||||
// Load or create category description files
|
||||
const descriptionsDir = path.join(__dirname, 'descriptions');
|
||||
if (!fs.existsSync(descriptionsDir)) {
|
||||
fs.mkdirSync(descriptionsDir);
|
||||
}
|
||||
|
||||
const categoryDescriptions = {};
|
||||
for (const config of categoryConfigs) {
|
||||
const safeName = config.name.replace(/\s+/g, '_');
|
||||
const descPath = path.join(descriptionsDir, `${safeName}.txt`);
|
||||
|
||||
// Auto-create with default template if not exists
|
||||
if (!fs.existsSync(descPath)) {
|
||||
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
|
||||
fs.writeFileSync(descPath, defaultDesc, 'utf8');
|
||||
}
|
||||
|
||||
categoryDescriptions[config.name] = fs.readFileSync(descPath, 'utf8');
|
||||
|
||||
// Auto-create category details
|
||||
const detailsDir = path.join(__dirname, 'details');
|
||||
if (!fs.existsSync(detailsDir)) {
|
||||
fs.mkdirSync(detailsDir);
|
||||
}
|
||||
const detailPath = path.join(detailsDir, `${safeName}.txt`);
|
||||
if (!fs.existsSync(detailPath)) {
|
||||
const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{scent}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
|
||||
fs.writeFileSync(detailPath, defaultDetail, 'utf8');
|
||||
}
|
||||
categoryConfigs[config.name] = categoryConfigs[config.name] || {};
|
||||
categoryConfigs[config.name].detailsTemplate = fs.readFileSync(detailPath, 'utf8');
|
||||
}
|
||||
|
||||
// 0. Seed Admin User
|
||||
const adminPassword = await bcrypt.hash('irelax123', 10);
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
update: { password: adminPassword },
|
||||
create: {
|
||||
username: 'admin',
|
||||
password: adminPassword,
|
||||
role: 'ADMIN'
|
||||
}
|
||||
});
|
||||
process.stdout.write('✅ Seeded Admin User (admin / irelax123).\n');
|
||||
|
||||
// 1. Seed Scents & Categories
|
||||
const scents = [];
|
||||
for (const name of scentNames) {
|
||||
const s = await prisma.scent.upsert({
|
||||
where: { name },
|
||||
update: {},
|
||||
create: { name }
|
||||
});
|
||||
scents.push(s);
|
||||
}
|
||||
process.stdout.write(`✅ Seeded ${scents.length} Scents.\n`);
|
||||
|
||||
const categories = [];
|
||||
for (const config of categoryConfigs) {
|
||||
const c = await prisma.category.upsert({
|
||||
where: { name: config.name },
|
||||
update: {},
|
||||
create: { name: config.name }
|
||||
});
|
||||
categories.push({ ...c, allowedSizes: config.sizes, priceMap: config.priceMap, detailsTemplate: categoryConfigs[config.name].detailsTemplate });
|
||||
}
|
||||
process.stdout.write(`✅ Seeded ${categories.length} Categories.\n`);
|
||||
|
||||
// 2. Cross Scents x Categories to create Products
|
||||
process.stdout.write('📦 Generating Products and Variants...\n');
|
||||
let productCount = 0;
|
||||
let variantCount = 0;
|
||||
|
||||
for (const scent of scents) {
|
||||
for (const category of categories) {
|
||||
const productName = `${scent.name} ${category.name}`;
|
||||
|
||||
// Get category description template and replace {{scent}}
|
||||
let productDescription = categoryDescriptions[category.name] || `Product ${scent.name} in category ${category.name}`;
|
||||
productDescription = productDescription.replace(/{{scent}}/g, scent.name);
|
||||
|
||||
let productDetails = category.detailsTemplate || '';
|
||||
productDetails = productDetails.replace(/{{scent}}/g, scent.name);
|
||||
|
||||
// Upsert Product
|
||||
const product = await prisma.product.upsert({
|
||||
where: {
|
||||
scentId_categoryId: {
|
||||
scentId: scent.id,
|
||||
categoryId: category.id
|
||||
}
|
||||
},
|
||||
update: {
|
||||
name: productName,
|
||||
description: productDescription,
|
||||
details: productDetails
|
||||
},
|
||||
create: {
|
||||
name: productName,
|
||||
scentId: scent.id,
|
||||
categoryId: category.id,
|
||||
description: productDescription,
|
||||
details: productDetails
|
||||
}
|
||||
});
|
||||
productCount++;
|
||||
|
||||
// 3. Create Default Product Image
|
||||
// Using existing category-based image paths as initial seed data
|
||||
const defaultImagePath = category.name === 'Oil Ozone' || category.name === 'Aroma Oil' || category.name === 'Car Diffuser'
|
||||
? '/images/oil_ozone.png'
|
||||
: '/images/reed_diffuser.png';
|
||||
|
||||
await prisma.productImage.upsert({
|
||||
where: { id: (product.id * 10) + 1 }, // Pseudo-stable ID for seed
|
||||
update: { url: defaultImagePath },
|
||||
create: {
|
||||
id: (product.id * 10) + 1,
|
||||
productId: product.id,
|
||||
url: defaultImagePath,
|
||||
order: 0
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fallback for cases where ID might conflict or upsert on non-unique field
|
||||
return prisma.productImage.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
url: defaultImagePath,
|
||||
order: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Create Variants (Sizes) with prices from priceMap
|
||||
for (const size of category.allowedSizes) {
|
||||
const price = category.priceMap?.[size] ?? 0;
|
||||
await prisma.productVariant.upsert({
|
||||
where: {
|
||||
productId_size: {
|
||||
productId: product.id,
|
||||
size: size
|
||||
}
|
||||
},
|
||||
update: { price }, // อัปเดตราคาเมื่อ re-seed
|
||||
create: {
|
||||
productId: product.id,
|
||||
size: size,
|
||||
price: price
|
||||
}
|
||||
});
|
||||
variantCount++;
|
||||
}
|
||||
}
|
||||
process.stdout.write(`.`); // Progress indicator
|
||||
}
|
||||
|
||||
process.stdout.write(`\n✨ Seeding Complete!\n`);
|
||||
process.stdout.write(`📊 Summary:\n`);
|
||||
process.stdout.write(` - Scents: ${scents.length}\n`);
|
||||
process.stdout.write(` - Categories: ${categories.length}\n`);
|
||||
process.stdout.write(` - Products: ${productCount}\n`);
|
||||
process.stdout.write(` - Variants: ${variantCount}\n`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
process.stderr.write(`❌ Error: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,93 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { categoriesRoute } from "./routes/categories";
|
||||
import { productsRoute } from "./routes/products";
|
||||
import { authRoute } from "./routes/auth";
|
||||
import { uploadRoute } from "./routes/upload";
|
||||
import { heroRoute } from "./routes/hero";
|
||||
import { ordersRoute } from "./routes/orders";
|
||||
import { settingsRoute } from "./routes/settings";
|
||||
import { articlesRoute } from "./routes/articles";
|
||||
import { tiktokRoute } from "./routes/tiktok";
|
||||
import { rawMaterialsRoute } from "./routes/raw-materials";
|
||||
import { shopeeRoute } from "./routes/shopee";
|
||||
import { tiktokOrderRoute } from "./routes/tiktokOrder";
|
||||
import { lazadaOrderRoute } from "./routes/lazadaOrder";
|
||||
import { customersRoute } from "./routes/customers";
|
||||
import { couponsRoute } from "./routes/coupons";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
cors({
|
||||
origin: [
|
||||
...CORS_ORIGIN.split(",").map((o) => o.trim()),
|
||||
"https://admin.homaroma.shop",
|
||||
"https://line.homaroma.shop", // LIFF
|
||||
"https://homaroma.shop", // Main Website
|
||||
"http://localhost:5174", // Admin
|
||||
"http://localhost:4173",
|
||||
"http://localhost:3001", // SSR Next.js
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: JWT_SECRET,
|
||||
exp: "24h",
|
||||
})
|
||||
)
|
||||
.use(
|
||||
swagger({
|
||||
documentation: {
|
||||
info: {
|
||||
title: "HomAroma API",
|
||||
version: "1.0.0",
|
||||
description: "API for HomAroma & Essential Oil Products",
|
||||
},
|
||||
tags: [
|
||||
{ name: "categories", description: "Product categories" },
|
||||
{ name: "products", description: "Products and variants" },
|
||||
{ name: "auth", description: "Authentication for Admin" },
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.use(staticPlugin({
|
||||
assets: "public",
|
||||
prefix: "", // use empty string for root
|
||||
}))
|
||||
.get("/", () => ({
|
||||
name: "HomAroma API",
|
||||
version: "1.0.0",
|
||||
status: "running",
|
||||
docs: `/swagger`,
|
||||
}))
|
||||
// Explicit route for uploads to guarantee it works even if staticPlugin fails
|
||||
.get("/uploads/:filename", ({ params: { filename }, set }) => {
|
||||
const file = Bun.file(`public/uploads/${filename}`);
|
||||
if (file.size === 0) {
|
||||
set.status = 404;
|
||||
return "File not found";
|
||||
}
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute)
|
||||
)
|
||||
.listen(PORT);
|
||||
|
||||
console.log(
|
||||
`🌿 HomAroma API is running at http://${app.server?.hostname}:${app.server?.port}`
|
||||
);
|
||||
console.log(
|
||||
`📖 Swagger docs: http://${app.server?.hostname}:${app.server?.port}/swagger`
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
export default prisma;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const articlesRoute = new Elysia({ prefix: "/articles" })
|
||||
.get("/", async ({ query, set }) => {
|
||||
try {
|
||||
const { publishedOnly } = query as { publishedOnly?: string };
|
||||
const where = publishedOnly === 'true' ? { isPublished: true } : {};
|
||||
|
||||
return await prisma.article.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to load articles" };
|
||||
}
|
||||
})
|
||||
.get("/:slug", async ({ params: { slug }, set }) => {
|
||||
try {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
if (!article) {
|
||||
set.status = 404;
|
||||
return { error: "Article not found" };
|
||||
}
|
||||
return article;
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch article" };
|
||||
}
|
||||
})
|
||||
.get("/id/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { id: parseInt(id, 10) }
|
||||
});
|
||||
if (!article) {
|
||||
set.status = 404;
|
||||
return { error: "Article not found" };
|
||||
}
|
||||
return article;
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch article" };
|
||||
}
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.article.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
coverImage: data.coverImage,
|
||||
content: data.content,
|
||||
isPublished: data.isPublished
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create article. Ensure slug is unique." };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String(),
|
||||
slug: t.String(),
|
||||
coverImage: t.Optional(t.String()),
|
||||
content: t.String(),
|
||||
isPublished: t.Boolean()
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.article.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
coverImage: data.coverImage,
|
||||
content: data.content,
|
||||
isPublished: data.isPublished
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update article" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String(),
|
||||
slug: t.String(),
|
||||
coverImage: t.Optional(t.String()),
|
||||
content: t.String(),
|
||||
isPublished: t.Boolean()
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.article.delete({
|
||||
where: { id: parseInt(id, 10) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete article" };
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const authRoute = new Elysia({ prefix: "/auth" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: process.env.JWT_SECRET || "irelax_secret_2026_scent",
|
||||
exp: "24h",
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/login",
|
||||
async ({ body, jwt, set }) => {
|
||||
const { username, password } = body;
|
||||
|
||||
// 1. Find user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Invalid credentials" };
|
||||
}
|
||||
|
||||
// 2. Check password
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Invalid credentials" };
|
||||
}
|
||||
|
||||
// 3. Generate Token
|
||||
const token = await jwt.sign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
username: t.String(),
|
||||
password: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Admin Login",
|
||||
tags: ["auth"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/line",
|
||||
async ({ body, jwt, set }) => {
|
||||
const { lineUserId, displayName, pictureUrl, email, phone } = body;
|
||||
|
||||
// Upsert Customer
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { lineUserId },
|
||||
update: {
|
||||
displayName,
|
||||
pictureUrl,
|
||||
...(email && { email }),
|
||||
...(phone && { phone }),
|
||||
},
|
||||
create: {
|
||||
lineUserId,
|
||||
displayName,
|
||||
pictureUrl,
|
||||
email,
|
||||
phone,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Token
|
||||
const token = await jwt.sign({
|
||||
id: customer.id,
|
||||
lineUserId: customer.lineUserId,
|
||||
role: "CUSTOMER",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
customer,
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
lineUserId: t.String(),
|
||||
displayName: t.Optional(t.String()),
|
||||
pictureUrl: t.Optional(t.String()),
|
||||
email: t.Optional(t.String()),
|
||||
phone: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
summary: "LINE Login for Customers",
|
||||
tags: ["auth"],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
.get("/", async () => {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { products: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return categories;
|
||||
})
|
||||
.get("/:id/products", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
// Pagination params
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 12;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [category, total] = await Promise.all([
|
||||
prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
products: {
|
||||
include: {
|
||||
scent: true,
|
||||
category: true,
|
||||
variants: { orderBy: { price: "asc" } },
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
},
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
},
|
||||
}),
|
||||
prisma.product.count({ where: { categoryId: id } }),
|
||||
]);
|
||||
|
||||
if (!category) return status(404, { message: "Category not found" });
|
||||
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
imageUrl: category.imageUrl,
|
||||
_count: category._count,
|
||||
},
|
||||
data: category.products,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
try {
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
imageUrl: body.imageUrl,
|
||||
},
|
||||
});
|
||||
return category;
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
return status(400, { message: "Category name already exists" });
|
||||
}
|
||||
return status(500, { message: "Failed to create category" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
try {
|
||||
const category = await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
imageUrl: body.imageUrl,
|
||||
},
|
||||
});
|
||||
return category;
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2025") {
|
||||
return status(404, { message: "Category not found" });
|
||||
}
|
||||
if (err.code === "P2002") {
|
||||
return status(400, { message: "Category name already exists" });
|
||||
}
|
||||
console.error("Update category error:", err);
|
||||
return status(500, { message: "Failed to update category" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
try {
|
||||
// Ensure no products are attached, or decide if we should cascade delete
|
||||
const category = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true } } }
|
||||
});
|
||||
|
||||
if (!category) return status(404, { message: "Category not found" });
|
||||
if (category._count.products > 0) {
|
||||
return status(400, { message: "Cannot delete category with existing products" });
|
||||
}
|
||||
|
||||
await prisma.category.delete({ where: { id } });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return status(500, { message: "Failed to delete category" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
// Get all coupons (Admin)
|
||||
.get("/", async () => {
|
||||
return await prisma.coupon.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
})
|
||||
// Get active coupons (Storefront)
|
||||
.get("/active", async () => {
|
||||
const now = new Date();
|
||||
return await prisma.coupon.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: now } }
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
})
|
||||
// Create new coupon (Admin)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
|
||||
// Check if code already exists
|
||||
const existing = await prisma.coupon.findUnique({
|
||||
where: { code: data.code.toUpperCase() }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
set.status = 400;
|
||||
return { error: "Coupon code already exists" };
|
||||
}
|
||||
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: {
|
||||
code: data.code.toUpperCase(),
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
minPurchase: data.minPurchase || 0,
|
||||
maxDiscount: data.maxDiscount || null,
|
||||
usageLimit: data.usageLimit || null,
|
||||
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
|
||||
isActive: data.isActive !== undefined ? data.isActive : true
|
||||
}
|
||||
});
|
||||
return coupon;
|
||||
} catch (err) {
|
||||
console.error("[Coupons] Create Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.String(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Optional(t.Number()),
|
||||
maxDiscount: t.Optional(t.Nullable(t.Number())),
|
||||
usageLimit: t.Optional(t.Nullable(t.Number())),
|
||||
expiresAt: t.Optional(t.Nullable(t.String())),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
// Update coupon (Admin)
|
||||
.patch("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(data.code && { code: data.code.toUpperCase() }),
|
||||
...(data.type && { type: data.type }),
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
|
||||
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
|
||||
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
|
||||
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive })
|
||||
}
|
||||
});
|
||||
return coupon;
|
||||
} catch (err) {
|
||||
console.error("[Coupons] Update Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.Optional(t.String()),
|
||||
type: t.Optional(t.String()),
|
||||
value: t.Optional(t.Number()),
|
||||
minPurchase: t.Optional(t.Number()),
|
||||
maxDiscount: t.Optional(t.Nullable(t.Number())),
|
||||
usageLimit: t.Optional(t.Nullable(t.Number())),
|
||||
expiresAt: t.Optional(t.Nullable(t.String())),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
// Delete coupon (Admin)
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.coupon.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete coupon" };
|
||||
}
|
||||
})
|
||||
// Validate coupon (Storefront)
|
||||
.post("/validate", async ({ body, set }) => {
|
||||
try {
|
||||
const { code, cartTotal } = body as any;
|
||||
const upperCode = code.toUpperCase();
|
||||
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { code: upperCode }
|
||||
});
|
||||
|
||||
if (!coupon) {
|
||||
set.status = 404;
|
||||
return { valid: false, error: "Coupon not found" };
|
||||
}
|
||||
|
||||
if (!coupon.isActive) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon is no longer active" };
|
||||
}
|
||||
|
||||
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon has expired" };
|
||||
}
|
||||
|
||||
if (coupon.usageLimit && coupon.usedCount >= coupon.usageLimit) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon usage limit reached" };
|
||||
}
|
||||
|
||||
if (cartTotal < coupon.minPurchase) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: `Minimum purchase of ฿${coupon.minPurchase} required` };
|
||||
}
|
||||
|
||||
// Calculate discount amount
|
||||
let discountAmount = 0;
|
||||
if (coupon.type === "PERCENTAGE") {
|
||||
discountAmount = cartTotal * (coupon.value / 100);
|
||||
if (coupon.maxDiscount && discountAmount > coupon.maxDiscount) {
|
||||
discountAmount = coupon.maxDiscount;
|
||||
}
|
||||
} else if (coupon.type === "FIXED_AMOUNT") {
|
||||
discountAmount = coupon.value;
|
||||
} else if (coupon.type === "FREE_SHIPPING") {
|
||||
// We handle free shipping separately, returning discountAmount = 0 but a flag
|
||||
return {
|
||||
valid: true,
|
||||
coupon,
|
||||
discountAmount: 0,
|
||||
isFreeShipping: true
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure discount doesn't exceed cart total
|
||||
if (discountAmount > cartTotal) {
|
||||
discountAmount = cartTotal;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
coupon,
|
||||
discountAmount,
|
||||
isFreeShipping: false
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Coupons] Validate Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to validate coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.String(),
|
||||
cartTotal: t.Number()
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Elysia } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||
.get("/", async () => {
|
||||
return await prisma.customer.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { orders: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/:id", async ({ params: { id }, set }) => {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: { orders: { orderBy: { createdAt: 'desc' } } }
|
||||
});
|
||||
if (!customer) {
|
||||
set.status = 404;
|
||||
return { error: "Customer not found" };
|
||||
}
|
||||
return customer;
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const heroRoute = new Elysia({ prefix: "/hero" })
|
||||
.get("/", async () => {
|
||||
return await prisma.hero.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
}) || await prisma.hero.findFirst({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.get("/all", async () => {
|
||||
return await prisma.hero.findMany({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
const { id, ...heroData } = data;
|
||||
|
||||
if (id) {
|
||||
return await prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: heroData
|
||||
});
|
||||
} else {
|
||||
return await prisma.hero.create({
|
||||
data: heroData
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Hero] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save hero data" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
tagline: t.String(),
|
||||
title: t.String(),
|
||||
description: t.String(),
|
||||
imageUrl: t.String(),
|
||||
buttonText: t.String(),
|
||||
buttonLink: t.String(),
|
||||
isActive: t.Boolean()
|
||||
})
|
||||
})
|
||||
.patch("/:id/activate", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.hero.updateMany({
|
||||
data: { isActive: false }
|
||||
}),
|
||||
prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: { isActive: true }
|
||||
})
|
||||
]);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to activate hero" };
|
||||
}
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.hero.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete hero" };
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
if (val === null || val === undefined || val === "") return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
const str = (val: any): string => String(val ?? "").trim();
|
||||
|
||||
const MONTHS: Record<string, string> = { Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' };
|
||||
|
||||
const parseLazadaDate = (val: any): string => {
|
||||
const d = str(val);
|
||||
if (!d) return "";
|
||||
// e.g. "25 Apr 2026 23:24"
|
||||
const parts = d.split(" ");
|
||||
if (parts.length >= 4) {
|
||||
const [day, monthStr, year, time] = parts;
|
||||
const month = MONTHS[monthStr] || "01";
|
||||
return `${year}-${month}-${day.padStart(2, "0")} ${time}:00`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
|
||||
|
||||
// ── Import (Excel rows as JSON) ───────────────────────────────────────────
|
||||
.post(
|
||||
"/import",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body as { rows: any[]; shop?: string };
|
||||
const shopName = shop || "hom-aroma";
|
||||
|
||||
console.log(`[LazadaOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
|
||||
// Group rows by orderNumber
|
||||
const orderMap = new Map<string, { meta: any; items: any[] }>();
|
||||
for (const row of rows) {
|
||||
const orderNumber = str(row["orderNumber"]);
|
||||
if (!orderNumber) continue;
|
||||
if (!orderMap.has(orderNumber)) orderMap.set(orderNumber, { meta: row, items: [] });
|
||||
orderMap.get(orderNumber)!.items.push(row);
|
||||
}
|
||||
|
||||
console.log(`[LazadaOrder] Grouped into ${orderMap.size} unique orders`);
|
||||
|
||||
const CHUNK = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderNumber, { meta, items }] of chunk) {
|
||||
// Sync strategy: delete old items
|
||||
await tx.lazadaOrderItem.deleteMany({ where: { orderNumber } });
|
||||
|
||||
let totalPaidPrice = 0;
|
||||
let totalShippingFee = 0;
|
||||
|
||||
const itemsData = items.map((item) => {
|
||||
const paidPrice = toNum(item["paidPrice"]);
|
||||
const shippingFee = toNum(item["shippingFee"]);
|
||||
totalPaidPrice += paidPrice;
|
||||
totalShippingFee += shippingFee;
|
||||
|
||||
return {
|
||||
orderItemId: str(item["orderItemId"]),
|
||||
sellerSku: str(item["sellerSku"]),
|
||||
lazadaSku: str(item["lazadaSku"]),
|
||||
itemName: str(item["itemName"]),
|
||||
variation: str(item["variation"]),
|
||||
unitPrice: toNum(item["unitPrice"]),
|
||||
paidPrice,
|
||||
sellerDiscountTotal: toNum(item["sellerDiscountTotal"]),
|
||||
platformDiscountTotal: toNum(item["platformDiscountTotal"]),
|
||||
shippingFee,
|
||||
status: str(item["status"]),
|
||||
};
|
||||
});
|
||||
|
||||
// Decide order status based on items (or first item)
|
||||
// We'll just use the first item's status if they share it
|
||||
const orderStatus = str(meta["status"]);
|
||||
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
createTime: parseLazadaDate(meta["createTime"]),
|
||||
updateTime: parseLazadaDate(meta["updateTime"]),
|
||||
deliveredDate: parseLazadaDate(meta["deliveredDate"]),
|
||||
customerName: str(meta["customerName"]),
|
||||
shippingCity: str(meta["shippingCity"]),
|
||||
shippingPostCode: str(meta["shippingPostCode"]),
|
||||
shippingRegion: str(meta["shippingRegion"]),
|
||||
payMethod: str(meta["payMethod"]),
|
||||
status: orderStatus,
|
||||
trackingCode: str(meta["trackingCode"]),
|
||||
shippingProvider: str(meta["shippingProvider"]),
|
||||
orderAmount: totalPaidPrice,
|
||||
shippingFee: totalShippingFee,
|
||||
};
|
||||
|
||||
await tx.lazadaOrder.upsert({
|
||||
where: { orderNumber },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderNumber, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[LazadaOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
|
||||
console.log(`[LazadaOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
} catch (error: any) {
|
||||
console.error(`[LazadaOrder] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
},
|
||||
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||
)
|
||||
|
||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||
.delete("/clear", async ({ query, set }) => {
|
||||
try {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const result = await prisma.lazadaOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
} catch (error: any) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderNumber", async ({ params, set }) => {
|
||||
const order = await prisma.lazadaOrder.findUnique({ where: { orderNumber: params.orderNumber }, include: { items: true } });
|
||||
if (!order) { set.status = 404; return { error: "Order not found" }; }
|
||||
return order;
|
||||
})
|
||||
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.lazadaOrder.findMany({ where, include: { items: true }, orderBy: { createTime: "desc" }, skip, take: limit }),
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg] = await Promise.all([
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
prisma.lazadaOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Analytics (Single Shop) ───────────────────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const dateFrom = query.dateFrom as string | undefined;
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
const where: any = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom) where.createTime.gte = dateFrom;
|
||||
if (dateTo) where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true, payMethod: true,
|
||||
items: { select: { itemName: true, variation: true, sellerDiscountTotal: true, platformDiscountTotal: true, paidPrice: true, sellerSku: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
const m = monthMap.get(month)!;
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
if (o.status?.toLowerCase().includes("canceled") || o.status?.toLowerCase().includes("failed")) m.cancelled += 1;
|
||||
m.items += o.items.length;
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Status breakdown
|
||||
const statusMap = new Map<string, number>();
|
||||
for (const o of allOrders) {
|
||||
const key = o.status || "Unknown";
|
||||
statusMap.set(key, (statusMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
// Payment method breakdown
|
||||
const payMap = new Map<string, number>();
|
||||
for (const o of allOrders) {
|
||||
const key = o.payMethod || "Unknown";
|
||||
payMap.set(key, (payMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
// Top products
|
||||
const productMap = new Map<string, { name: string; variation: string; qty: number; revenue: number }>();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += 1; // Lazada export is 1 row per item quantity usually, so we add 1
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status?.toLowerCase().includes("canceled")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalPlatformDisc, totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping, // Simple net (excluding lazada fees which aren't in this report usually)
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom as string | undefined;
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
const where: any = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom) where.createTime.gte = dateFrom;
|
||||
if (dateTo) where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, shop: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true,
|
||||
items: { select: { platformDiscountTotal: true, sellerDiscountTotal: true, paidPrice: true, itemName: true, variation: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Per-shop summary
|
||||
const shopMap = new Map<string, {
|
||||
shop: string; orders: number; completed: number; cancelled: number;
|
||||
revenue: number; shippingFee: number; platformDiscount: number; sellerDiscount: number; items: number;
|
||||
}>();
|
||||
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop)!;
|
||||
s.orders += 1;
|
||||
s.revenue += o.orderAmount;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.platformDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.platformDiscountTotal), 0);
|
||||
s.sellerDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.sellerDiscountTotal), 0);
|
||||
s.items += o.items.length;
|
||||
|
||||
if (o.status?.toLowerCase().includes("canceled")) s.cancelled += 1;
|
||||
else s.completed += 1;
|
||||
}
|
||||
|
||||
const shops = Array.from(shopMap.values());
|
||||
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map<string, Map<string, { revenue: number; orders: number }>>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month)) monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month)!;
|
||||
if (!sm.has(o.shop)) sm.set(o.shop, { revenue: 0, orders: 0 });
|
||||
const ms = sm.get(o.shop)!;
|
||||
ms.revenue += o.orderAmount;
|
||||
ms.orders += 1;
|
||||
}
|
||||
const shopKeys = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row: any = { month };
|
||||
for (const shop of shopKeys) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
row.revenue = row.totalRevenue; // Provide fallback for global view
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Top Products Global
|
||||
const productMap = new Map<string, { name: string; variation: string; qty: number; revenue: number }>();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += 1;
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Global summary
|
||||
const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0);
|
||||
const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0);
|
||||
const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0);
|
||||
const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0);
|
||||
const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0);
|
||||
const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0);
|
||||
const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0);
|
||||
|
||||
return {
|
||||
shops,
|
||||
monthly,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0,
|
||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
.get("/", async () => {
|
||||
return await prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
})
|
||||
.get("/:id", async ({ params: { id }, set }) => {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
customerId: data.customerId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
shippingAddress: data.shippingAddress,
|
||||
totalAmount: data.totalAmount,
|
||||
shippingFee: data.shippingFee,
|
||||
couponCode: data.couponCode || null,
|
||||
discountAmount: data.discountAmount || 0,
|
||||
items: {
|
||||
create: data.items.map((item: any) => ({
|
||||
productId: item.productId,
|
||||
variantId: item.variantId,
|
||||
quantity: item.quantity,
|
||||
priceAtPurchase: item.priceAtPurchase,
|
||||
productName: item.productName,
|
||||
variantSize: item.variantSize,
|
||||
productImage: item.productImage
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
// If a coupon was used, increment its usedCount
|
||||
if (data.couponCode) {
|
||||
try {
|
||||
await prisma.coupon.update({
|
||||
where: { code: data.couponCode.toUpperCase() },
|
||||
data: { usedCount: { increment: 1 } }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to increment coupon usedCount:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
} catch (err) {
|
||||
console.error("[Orders] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create order" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
customerId: t.Optional(t.Nullable(t.Number())),
|
||||
customerName: t.String(),
|
||||
customerEmail: t.String(),
|
||||
customerPhone: t.String(),
|
||||
shippingAddress: t.String(),
|
||||
totalAmount: t.Number(),
|
||||
shippingFee: t.Number(),
|
||||
couponCode: t.Optional(t.Nullable(t.String())),
|
||||
discountAmount: t.Optional(t.Number()),
|
||||
items: t.Array(t.Object({
|
||||
productId: t.Number(),
|
||||
variantId: t.Optional(t.Number()),
|
||||
quantity: t.Number(),
|
||||
priceAtPurchase: t.Number(),
|
||||
productName: t.String(),
|
||||
variantSize: t.Optional(t.String()),
|
||||
productImage: t.Optional(t.String())
|
||||
}))
|
||||
})
|
||||
})
|
||||
.patch("/:id/status", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { status } = body as any;
|
||||
return await prisma.order.update({
|
||||
where: { id: Number(id) },
|
||||
data: { status },
|
||||
include: { items: true }
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update order status" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
status: t.String()
|
||||
})
|
||||
})
|
||||
.patch("/:id/slip", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { paymentSlipUrl } = body as any;
|
||||
return await prisma.order.update({
|
||||
where: { id: Number(id) },
|
||||
data: { paymentSlipUrl },
|
||||
include: { items: true }
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to upload slip" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
paymentSlipUrl: t.String()
|
||||
})
|
||||
})
|
||||
.post("/track", async ({ body, set }) => {
|
||||
try {
|
||||
const { orderId, phone } = body as any;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: Number(orderId) }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
|
||||
const sanitizePhone = (p: string) => p.replace(/[^0-9]/g, '');
|
||||
if (sanitizePhone(order.customerPhone) !== sanitizePhone(phone)) {
|
||||
set.status = 401;
|
||||
return { error: "Authentication failed. Phone number does not match." };
|
||||
}
|
||||
|
||||
return { success: true, orderId: order.id };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Invalid tracking request" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
orderId: t.Number(),
|
||||
phone: t.String()
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.get("/", async ({ query }) => {
|
||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||
const search = query.search?.trim() || undefined;
|
||||
|
||||
// Pagination params
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 12;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
...(categoryId && { categoryId }),
|
||||
...(scentId && { scentId }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ scent: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
],
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
scent: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
console.log(`Fetch products paginated: count=${products.length}, total=${total}`)
|
||||
if (products.length > 0) {
|
||||
console.log(`Sample product ${products[0].id} images count: ${products[0].images?.length || 0}`)
|
||||
}
|
||||
|
||||
return {
|
||||
data: products,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
})
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: true,
|
||||
scent: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
return product;
|
||||
})
|
||||
.put(
|
||||
"/:id",
|
||||
async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const { categoryId, description, details, variants, images } = body;
|
||||
console.log(`[Product Update] ID=${id}, Category=${categoryId}, Variants=${variants?.length || 0}, Images=${images?.length || 0}`);
|
||||
|
||||
try {
|
||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||
// 1. Update basic product info
|
||||
const prod = await tx.product.update({
|
||||
where: { id },
|
||||
data: { categoryId, description, details },
|
||||
});
|
||||
|
||||
// 2. Sync Variants
|
||||
await tx.productVariant.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
|
||||
if (variants && variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
await tx.productVariant.create({
|
||||
data: {
|
||||
productId: id,
|
||||
size: v.size,
|
||||
price: v.price,
|
||||
bomItems: v.bomItems && v.bomItems.length > 0 ? {
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Images
|
||||
await tx.productImage.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
|
||||
if (images && images.length > 0) {
|
||||
// Using a loop instead of createMany to be more robust with Postgres sequence/ID management
|
||||
// and avoid unique constraint collisions if IDs were somehow kept in body
|
||||
for (const [idx, img] of images.entries()) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: id,
|
||||
url: img.url,
|
||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return prod;
|
||||
});
|
||||
|
||||
return { success: true, product: updatedProduct };
|
||||
} catch (err: any) {
|
||||
console.error("Update product error:", err);
|
||||
return status(500, { success: false, message: "Failed to update product" });
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
categoryId: t.Number(),
|
||||
description: t.Optional(t.String()),
|
||||
details: t.Optional(t.String()),
|
||||
variants: t.Array(
|
||||
t.Object({
|
||||
size: t.String(),
|
||||
price: t.Number(),
|
||||
bomItems: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
rawMaterialId: t.Number(),
|
||||
quantity: t.Number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
})
|
||||
),
|
||||
images: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
url: t.String(),
|
||||
order: t.Optional(t.Number()),
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Update Product",
|
||||
tags: ["products"],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
|
||||
.get("/", async () => {
|
||||
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return rawMaterials;
|
||||
})
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid ID" });
|
||||
|
||||
const rawMaterial = await prisma.rawMaterial.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!rawMaterial) return status(404, { message: "Raw material not found" });
|
||||
return rawMaterial;
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
try {
|
||||
const rawMaterial = await prisma.rawMaterial.create({
|
||||
data: body,
|
||||
});
|
||||
return status(201, rawMaterial);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to create raw material" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
unit: t.String(),
|
||||
pricePerUnit: t.Number(),
|
||||
}),
|
||||
detail: { tags: ["raw-materials"] }
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid ID" });
|
||||
|
||||
try {
|
||||
const rawMaterial = await prisma.rawMaterial.update({
|
||||
where: { id },
|
||||
data: body,
|
||||
});
|
||||
return rawMaterial;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to update raw material" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
unit: t.String(),
|
||||
pricePerUnit: t.Number(),
|
||||
}),
|
||||
detail: { tags: ["raw-materials"] }
|
||||
})
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid ID" });
|
||||
|
||||
try {
|
||||
await prisma.rawMaterial.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to delete raw material" });
|
||||
}
|
||||
}, {
|
||||
detail: { tags: ["raw-materials"] }
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
.get("/", async ({ set }) => {
|
||||
try {
|
||||
let settings = await prisma.storeSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
return settings;
|
||||
} catch (err) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch settings" };
|
||||
}
|
||||
})
|
||||
.put("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
let settings = await prisma.storeSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({ data });
|
||||
} else {
|
||||
settings = await prisma.storeSettings.update({
|
||||
where: { id: settings.id },
|
||||
data
|
||||
});
|
||||
}
|
||||
return settings;
|
||||
} catch (err) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update settings" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
shippingFee: t.Number(),
|
||||
bankName: t.String(),
|
||||
bankAccountName: t.String(),
|
||||
bankAccountNumber: t.String()
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
if (val === null || val === undefined || val === "") return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||
// ── Import Orders ─────────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/import/orders",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body as { rows: any[]; shop?: string };
|
||||
const shopName = shop || "hom-aroma";
|
||||
|
||||
console.log(`[Shopee] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
|
||||
const orderMap = new Map<string, { meta: any; items: any[] }>();
|
||||
for (const row of rows) {
|
||||
const orderId = String(row["หมายเลขคำสั่งซื้อ"] || "").trim();
|
||||
if (!orderId) continue;
|
||||
if (!orderMap.has(orderId)) orderMap.set(orderId, { meta: row, items: [] });
|
||||
orderMap.get(orderId)!.items.push(row);
|
||||
}
|
||||
|
||||
console.log(`[Shopee] Grouped into ${orderMap.size} unique orders`);
|
||||
|
||||
const CHUNK_SIZE = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
|
||||
const chunk = entries.slice(i, i + CHUNK_SIZE);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderId, { meta, items }] of chunk) {
|
||||
await tx.shopeeOrderItem.deleteMany({ where: { orderId } });
|
||||
const itemsData = items.map((item) => ({
|
||||
parentSku: String(item["เลขอ้างอิง Parent SKU"] || ""),
|
||||
productName: String(item["ชื่อสินค้า"] || ""),
|
||||
skuRef: String(item["เลขอ้างอิง SKU (SKU Reference No.)"] || ""),
|
||||
variantName: String(item["ชื่อตัวเลือก"] || ""),
|
||||
originalPrice: toNum(item["ราคาตั้งต้น"]),
|
||||
dealPrice: toNum(item["ราคาขาย"]),
|
||||
quantity: Math.max(1, Math.round(toNum(item["จำนวน"]))),
|
||||
totalPayment: toNum(item["ยอดชำระเงิน"]),
|
||||
rawData: item,
|
||||
}));
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
status: String(meta["สถานะการสั่งซื้อ"] || ""),
|
||||
buyerUsername: String(meta["ชื่อผู้ใช้ (ผู้ซื้อ)"] || ""),
|
||||
orderCreatedAt: String(meta["วันที่ทำการสั่งซื้อ"] || ""),
|
||||
paymentTime: String(meta["เวลาการชำระสินค้า"] || ""),
|
||||
paymentMethod: String(meta["ช่องทางการชำระเงิน"] || ""),
|
||||
trackingNo: String(meta["*หมายเลขติดตามพัสดุ"] || ""),
|
||||
totalAmount: toNum(meta["จำนวนเงินทั้งหมด"]),
|
||||
buyerPaidAmount: toNum(meta["ราคาสินค้าที่ชำระโดยผู้ซื้อ (THB)"]),
|
||||
shippingFee: toNum(meta["ค่าจัดส่งที่ชำระโดยผู้ซื้อ"]),
|
||||
commission: toNum(meta["ค่าคอมมิชชั่น"]),
|
||||
completedAt: String(meta["เวลาที่ทำการสั่งซื้อสำเร็จ"] || ""),
|
||||
};
|
||||
await tx.shopeeOrder.upsert({
|
||||
where: { orderId },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderId, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[Shopee] Progress: ${Math.min(i + CHUNK_SIZE, entries.length)}/${entries.length}`);
|
||||
}
|
||||
|
||||
console.log(`[Shopee] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (จาก ${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
} catch (error: any) {
|
||||
console.error(`[Shopee] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
},
|
||||
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||
)
|
||||
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderId", async ({ params, set }) => {
|
||||
const { orderId } = params;
|
||||
const order = await prisma.shopeeOrder.findUnique({ where: { orderId }, include: { items: true } });
|
||||
if (!order) { set.status = 404; return { error: "Order not found" }; }
|
||||
return order;
|
||||
})
|
||||
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.shopeeOrder.findMany({ where, include: { items: true }, orderBy: { orderCreatedAt: "desc" }, skip, take: limit }),
|
||||
prisma.shopeeOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
|
||||
// ── GET Analytics (with date range filter) ───────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const dateFrom = query.dateFrom as string | undefined; // "YYYY-MM-DD"
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
// Build where — orderCreatedAt stored as "YYYY-MM-DD HH:mm" string
|
||||
const where: any = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.orderCreatedAt = {};
|
||||
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, status: true, orderCreatedAt: true,
|
||||
totalAmount: true, buyerPaidAmount: true, commission: true,
|
||||
shippingFee: true, paymentMethod: true,
|
||||
items: { select: { quantity: true, totalPayment: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map<string, { month: string; revenue: number; orders: number; commission: number; cancelled: number; items: number }>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.orderCreatedAt ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, commission: 0, cancelled: 0, items: 0 });
|
||||
const m = monthMap.get(month)!;
|
||||
m.orders += 1;
|
||||
m.revenue += o.totalAmount;
|
||||
m.commission += o.commission;
|
||||
if (o.status.includes("ยกเลิก")) m.cancelled += 1;
|
||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Status breakdown
|
||||
const statusMap = new Map<string, number>();
|
||||
for (const o of allOrders) {
|
||||
let status = o.status;
|
||||
if (status.includes("สำเร็จแล้ว")) status = "สำเร็จแล้ว";
|
||||
else if (status.includes("ได้รับสินค้าแล้ว")) status = "ได้รับสินค้าแล้ว";
|
||||
else if (status.includes("ยกเลิก")) status = "ยกเลิก";
|
||||
statusMap.set(status, (statusMap.get(status) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries())
|
||||
.map(([status, count]) => ({ status, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Payment method breakdown
|
||||
const paymentMap = new Map<string, { count: number; revenue: number }>();
|
||||
for (const o of allOrders) {
|
||||
const key = o.paymentMethod || "Unknown";
|
||||
if (!paymentMap.has(key)) paymentMap.set(key, { count: 0, revenue: 0 });
|
||||
const p = paymentMap.get(key)!;
|
||||
p.count += 1;
|
||||
p.revenue += o.buyerPaidAmount;
|
||||
}
|
||||
const paymentBreakdown = Array.from(paymentMap.entries())
|
||||
.map(([method, v]) => ({ method, ...v }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Top products (from filtered order IDs)
|
||||
const filteredOrderIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredOrderIds.length > 0
|
||||
? await prisma.shopeeOrderItem.findMany({
|
||||
where: { orderId: { in: filteredOrderIds } },
|
||||
select: { productName: true, variantName: true, quantity: true, totalPayment: true, dealPrice: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map<string, { name: string; variant: string; qty: number; revenue: number; orders: number }>();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variantName ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0, orders: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.totalPayment;
|
||||
p.orders += 1;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.totalAmount, 0);
|
||||
const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const completedOrders = allOrders.filter(o => !o.status.includes("ยกเลิก")).length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const netRevenue = totalRevenue - totalCommission - totalShipping;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalCommission, totalShipping, netRevenue,
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom as string | undefined;
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
const where: any = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.orderCreatedAt = {};
|
||||
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, shop: true, status: true, orderCreatedAt: true,
|
||||
totalAmount: true, buyerPaidAmount: true, commission: true,
|
||||
shippingFee: true,
|
||||
items: { select: { quantity: true, totalPayment: true } },
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
// Per-shop summary
|
||||
const shopMap = new Map<string, {
|
||||
shop: string; orders: number; completed: number; cancelled: number;
|
||||
revenue: number; commission: number; shippingFee: number; items: number;
|
||||
}>();
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, commission: 0, shippingFee: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop)!;
|
||||
s.orders += 1;
|
||||
s.revenue += o.totalAmount;
|
||||
s.commission += o.commission;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.items += o.items.reduce((acc, i) => acc + i.quantity, 0);
|
||||
if (o.status.includes("ยกเลิก")) s.cancelled += 1; else s.completed += 1;
|
||||
}
|
||||
const byShop = Array.from(shopMap.values()).map(s => ({
|
||||
...s,
|
||||
net: s.revenue - s.commission - s.shippingFee,
|
||||
cancelRate: s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0,
|
||||
aov: s.completed > 0 ? s.revenue / s.completed : 0,
|
||||
})).sort((a, b) => b.revenue - a.revenue);
|
||||
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map<string, Map<string, { revenue: number; orders: number; commission: number }>>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.orderCreatedAt ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month)) monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month)!;
|
||||
if (!sm.has(o.shop)) sm.set(o.shop, { revenue: 0, orders: 0, commission: 0 });
|
||||
const ms = sm.get(o.shop)!;
|
||||
ms.revenue += o.totalAmount;
|
||||
ms.orders += 1;
|
||||
ms.commission += o.commission;
|
||||
}
|
||||
const shops = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row: any = { month };
|
||||
for (const shop of shops) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0, commission: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Top products across all shops (filtered by date range via orderId)
|
||||
const filteredOrderIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredOrderIds.length > 0 ? await prisma.shopeeOrderItem.findMany({
|
||||
where: { orderId: { in: filteredOrderIds } },
|
||||
select: { productName: true, variantName: true, quantity: true, totalPayment: true, order: { select: { shop: true } } },
|
||||
}) : [];
|
||||
|
||||
const productMap = new Map<string, { name: string; variant: string; qty: number; revenue: number }>();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variantName ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.totalPayment;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Grand totals
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.totalAmount, 0);
|
||||
const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const totalCancelled = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
|
||||
return {
|
||||
byShop, monthly, topProducts, shops,
|
||||
totals: {
|
||||
totalRevenue, totalCommission, totalShipping,
|
||||
netRevenue: totalRevenue - totalCommission - totalShipping,
|
||||
totalOrders, totalCancelled,
|
||||
cancelRate: totalOrders > 0 ? (totalCancelled / totalOrders) * 100 : 0,
|
||||
aov: (totalOrders - totalCancelled) > 0 ? totalRevenue / (totalOrders - totalCancelled) : 0,
|
||||
commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
||||
prisma.shopeeOrder.count({ where }),
|
||||
prisma.shopeeOrder.aggregate({ where, _sum: { totalAmount: true, buyerPaidAmount: true, commission: true, shippingFee: true } }),
|
||||
prisma.shopeeOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, totalPayment: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.totalAmount ?? 0,
|
||||
buyerPaidAmount: agg._sum.buyerPaidAmount ?? 0,
|
||||
totalCommission: agg._sum.commission ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
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 as { data: any[] };
|
||||
|
||||
// ── 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: any) {
|
||||
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 as { data: any[] };
|
||||
|
||||
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: any) {
|
||||
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 as { data: any[] };
|
||||
|
||||
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: any) {
|
||||
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<any[]>`
|
||||
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: any) =>
|
||||
JSON.parse(JSON.stringify(data, (key, value) => (typeof value === "bigint" ? Number(value) : value)));
|
||||
|
||||
return serialize({
|
||||
summary: summaryList[0] || null,
|
||||
topGmv,
|
||||
topVolume,
|
||||
scentAnalysis
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
if (val === null || val === undefined || val === "") return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
// TikTok CSV values can have trailing \t — trim everything
|
||||
const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim();
|
||||
|
||||
// TikTok dates are "DD/MM/YYYY HH:mm:ss" -> convert to "YYYY-MM-DD HH:mm:ss" for sorting/filtering
|
||||
const parseTiktokDate = (val: any): string => {
|
||||
const d = str(val);
|
||||
if (!d) return "";
|
||||
const [datePart, timePart] = d.split(" ");
|
||||
if (!datePart) return d;
|
||||
const parts = datePart.split("/");
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")} ${timePart || "00:00:00"}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
|
||||
// ── Import (CSV rows as JSON) ──────────────────────────────────────────────
|
||||
.post(
|
||||
"/import",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body as { rows: any[]; shop?: string };
|
||||
const shopName = shop || "hom-aroma";
|
||||
|
||||
console.log(`[TiktokOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
|
||||
// Group rows by Order ID (trim tabs)
|
||||
const orderMap = new Map<string, { meta: any; items: any[] }>();
|
||||
for (const row of rows) {
|
||||
const orderId = str(row["Order ID"]);
|
||||
if (!orderId) continue;
|
||||
if (!orderMap.has(orderId)) orderMap.set(orderId, { meta: row, items: [] });
|
||||
orderMap.get(orderId)!.items.push(row);
|
||||
}
|
||||
|
||||
console.log(`[TiktokOrder] Grouped into ${orderMap.size} unique orders`);
|
||||
|
||||
const CHUNK = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderId, { meta, items }] of chunk) {
|
||||
// Delete old items first (sync strategy)
|
||||
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
|
||||
|
||||
const itemsData = items.map((item) => ({
|
||||
skuId: str(item["SKU ID"]),
|
||||
sellerSku: str(item["Seller SKU"]),
|
||||
productName: str(item["Product Name"]),
|
||||
variation: str(item["Variation"]),
|
||||
quantity: Math.max(1, Math.round(toNum(item["Quantity"]))),
|
||||
skuQtyReturn: Math.round(toNum(item["Sku Quantity of return"])),
|
||||
unitOriginalPrice: toNum(item["SKU Unit Original Price"]),
|
||||
subtotalBeforeDiscount: toNum(item["SKU Subtotal Before Discount"]),
|
||||
platformDiscount: toNum(item["SKU Platform Discount"]),
|
||||
sellerDiscount: toNum(item["SKU Seller Discount"]),
|
||||
subtotalAfterDiscount: toNum(item["SKU Subtotal After Discount"]),
|
||||
productCategory: str(item["Product Category"]),
|
||||
weight: toNum(item["Weight(kg)"]),
|
||||
}));
|
||||
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
status: str(meta["Order Status"]),
|
||||
subStatus: str(meta["Order Substatus"]),
|
||||
orderType: str(meta["Normal or Pre-order"]),
|
||||
cancelBy: str(meta["Cancel By"]),
|
||||
cancelReason: str(meta["Cancel Reason"]),
|
||||
fulfillmentType: str(meta["Fulfillment Type"]),
|
||||
warehouseName: str(meta["Warehouse Name"]),
|
||||
trackingId: str(meta["Tracking ID"]),
|
||||
deliveryOption: str(meta["Delivery Option"]),
|
||||
shippingProvider: str(meta["Shipping Provider Name"]),
|
||||
buyerUsername: str(meta["Buyer Username"]),
|
||||
paymentMethod: str(meta["Payment Method"]),
|
||||
orderAmount: toNum(meta["Order Amount"]),
|
||||
refundAmount: toNum(meta["Order Refund Amount"]),
|
||||
shippingFee: toNum(meta["Shipping Fee After Discount"]),
|
||||
originalShipping: toNum(meta["Original Shipping Fee"]),
|
||||
taxes: toNum(meta["Taxes"]),
|
||||
province: str(meta["Province"]),
|
||||
district: str(meta["District"]),
|
||||
country: str(meta["Country"]),
|
||||
createdTime: parseTiktokDate(meta["Created Time"]),
|
||||
paidTime: parseTiktokDate(meta["Paid Time"]),
|
||||
rtsTime: parseTiktokDate(meta["RTS Time"]),
|
||||
shippedTime: parseTiktokDate(meta["Shipped Time"]),
|
||||
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
|
||||
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
|
||||
};
|
||||
|
||||
await tx.tiktokOrder.upsert({
|
||||
where: { orderId },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderId, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
|
||||
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
} catch (error: any) {
|
||||
console.error(`[TiktokOrder] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
},
|
||||
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||
)
|
||||
|
||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||
.delete("/clear", async ({ query, set }) => {
|
||||
try {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
} catch (error: any) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderId", async ({ params, set }) => {
|
||||
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } });
|
||||
if (!order) { set.status = 404; return { error: "Order not found" }; }
|
||||
return order;
|
||||
})
|
||||
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }),
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }),
|
||||
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
totalRefund: agg._sum.refundAmount ?? 0,
|
||||
totalTaxes: agg._sum.taxes ?? 0,
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Analytics (date range filter) ────────────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const dateFrom = query.dateFrom as string | undefined;
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
const where: any = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdTime = {};
|
||||
if (dateFrom) where.createdTime.gte = dateFrom;
|
||||
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, status: true, subStatus: true, createdTime: true,
|
||||
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
|
||||
paymentMethod: true, province: true,
|
||||
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
const m = monthMap.get(month)!;
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1;
|
||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Status breakdown
|
||||
const statusMap = new Map<string, number>();
|
||||
for (const o of allOrders) {
|
||||
const key = o.status || "Unknown";
|
||||
statusMap.set(key, (statusMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
// Payment method breakdown
|
||||
const payMap = new Map<string, number>();
|
||||
for (const o of allOrders) {
|
||||
const key = o.paymentMethod || "Unknown";
|
||||
payMap.set(key, (payMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
// Top products
|
||||
const filteredIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredIds.length > 0
|
||||
? await prisma.tiktokOrderItem.findMany({
|
||||
where: { orderId: { in: filteredIds } },
|
||||
select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true, sellerSku: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map<string, { name: string; sku: string; variation: string; qty: number; revenue: number }>();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.productName, sku: item.sellerSku ?? "", variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.subtotalAfterDiscount;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom as string | undefined;
|
||||
const dateTo = query.dateTo as string | undefined;
|
||||
|
||||
const where: any = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdTime = {};
|
||||
if (dateFrom) where.createdTime.gte = dateFrom;
|
||||
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, shop: true, status: true, createdTime: true,
|
||||
orderAmount: true, shippingFee: true, refundAmount: true,
|
||||
items: { select: { quantity: true, platformDiscount: true, sellerDiscount: true, subtotalAfterDiscount: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Per-shop summary
|
||||
const shopMap = new Map<string, {
|
||||
shop: string; orders: number; completed: number; cancelled: number;
|
||||
revenue: number; shippingFee: number; platformDiscount: number; sellerDiscount: number; items: number;
|
||||
}>();
|
||||
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop)!;
|
||||
s.orders += 1;
|
||||
s.revenue += o.orderAmount;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.platformDiscount += o.items.reduce((acc, i) => acc + i.platformDiscount, 0);
|
||||
s.sellerDiscount += o.items.reduce((acc, i) => acc + i.sellerDiscount, 0);
|
||||
s.items += o.items.reduce((acc, i) => acc + i.quantity, 0);
|
||||
|
||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) s.cancelled += 1;
|
||||
else s.completed += 1;
|
||||
}
|
||||
|
||||
const shops = Array.from(shopMap.values());
|
||||
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map<string, Map<string, { revenue: number; orders: number }>>();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month)) monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month)!;
|
||||
if (!sm.has(o.shop)) sm.set(o.shop, { revenue: 0, orders: 0 });
|
||||
const ms = sm.get(o.shop)!;
|
||||
ms.revenue += o.orderAmount;
|
||||
ms.orders += 1;
|
||||
}
|
||||
const shopKeys = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row: any = { month };
|
||||
for (const shop of shopKeys) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
row.revenue = row.totalRevenue; // Fallback for global view
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Top Products Global
|
||||
const filteredIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredIds.length > 0
|
||||
? await prisma.tiktokOrderItem.findMany({
|
||||
where: { orderId: { in: filteredIds } },
|
||||
select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map<string, { name: string; variation: string; qty: number; revenue: number }>();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key)) productMap.set(key, { name: item.productName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key)!;
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.subtotalAfterDiscount;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
|
||||
// Global summary
|
||||
const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0);
|
||||
const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0);
|
||||
const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0);
|
||||
const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0);
|
||||
const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0);
|
||||
const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0);
|
||||
const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0);
|
||||
|
||||
return {
|
||||
shops,
|
||||
monthly,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0,
|
||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { join } from "path";
|
||||
import { mkdir } from "fs/promises";
|
||||
|
||||
export const uploadRoute = new Elysia({ prefix: "/upload" })
|
||||
.post("/", async ({ body, set }) => {
|
||||
const { file } = body as { file: File };
|
||||
|
||||
if (!file) {
|
||||
set.status = 400;
|
||||
return { error: "No file uploaded" };
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
set.status = 400;
|
||||
return { error: "Only image files are allowed" };
|
||||
}
|
||||
|
||||
console.log(`[Upload] Processing: ${file.name} (${(file.size / 1024).toFixed(2)} KB, ${file.type})`);
|
||||
|
||||
// Generate unique filename
|
||||
const ext = file.name.split(".").pop();
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const uploadDir = join(process.cwd(), "public", "uploads");
|
||||
const filePath = join(uploadDir, filename);
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// Write file using Bun.write
|
||||
await Bun.write(filePath, await file.arrayBuffer());
|
||||
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||
const publicUrl = `${APP_URL}/uploads/${filename}`;
|
||||
|
||||
console.log(`[Upload] Success: ${filename} -> ${publicUrl}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
filename
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Upload] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save file" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
file: t.File({
|
||||
maxSize: '5m',
|
||||
type: ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
})
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload product image",
|
||||
tags: ["upload"]
|
||||
}
|
||||
});
|
||||