Initial commit for homaroma

This commit is contained in:
Your Name committed 2026-06-03 08:39:12 +07:00
commit f2179cfe57
269 files changed
+22635

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+36
View File
@@ -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"
}
}
+16
View File
@@ -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"
+374
View File
@@ -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?
}
+225
View File
@@ -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();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+93
View File
@@ -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`
);
+13
View File
@@ -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;
+111
View File
@@ -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" };
}
});
+113
View File
@@ -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"],
},
}
);
+137
View File
@@ -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" });
}
});
+193
View File
@@ -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()
})
});
+25
View File
@@ -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;
});
+77
View File
@@ -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" };
}
});
+375
View File
@@ -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,
}
};
});
+153
View File
@@ -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()
})
});
+186
View File
@@ -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"],
},
}
);
+77
View File
@@ -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"] }
});
+45
View File
@@ -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()
})
});
+343
View File
@@ -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,
};
});
+304
View File
@@ -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
});
});
+393
View File
@@ -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,
}
};
});
+61
View File
@@ -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"]
}
});