Files
2026-06-03 19:40:04 +07:00

306 lines
11 KiB
TypeScript

import nextEnv from "@next/env";
import { getPool, query } from "../src/server/db/pool";
import {
createCategory,
createInventoryItem,
createProduct,
createShop,
createTable,
listCategories,
listInventory,
listProducts,
listShops,
listTables,
setShopStatus
} from "../src/server/db/queries";
import { verticalToCategoryId, type Vertical } from "../src/server/domain/catalog";
import { seedParityData } from "../src/server/services/parity";
nextEnv.loadEnvConfig(process.cwd());
type ProductSeed = {
name: string;
price: number;
category: string;
sku: string;
initialQuantity?: number;
};
type TableSeed = {
tableNumber: string;
capacity: number;
zone: string;
hourlyRate?: number;
};
type InventorySeed = {
name: string;
unit: string;
costPerUnit: number;
quantity: number;
reorderLevel: number;
supplierName: string;
};
type ShopSeed = {
name: string;
slug: string;
vertical: Vertical;
phone: string;
email: string;
description: string;
categories: string[];
products: ProductSeed[];
tables?: TableSeed[];
inventory?: InventorySeed[];
};
const shopSeeds: ShopSeed[] = [
{
name: "GoodGo Cafe MVP",
slug: "goodgo-cafe-mvp",
vertical: "cafe",
phone: "0900000000",
email: "cafe@goodgo.vn",
description: "Cafe fixture for TPOS MVP parity smoke tests",
categories: ["Coffee", "Tea", "Food"],
products: [
{ name: "Americano", price: 45000, category: "Coffee", sku: "CF-AMERICANO", initialQuantity: 40 },
{ name: "Latte", price: 59000, category: "Coffee", sku: "CF-LATTE", initialQuantity: 35 },
{ name: "Peach Tea", price: 52000, category: "Tea", sku: "TEA-PEACH", initialQuantity: 28 },
{ name: "Croissant", price: 39000, category: "Food", sku: "FD-CROISSANT", initialQuantity: 16 }
],
tables: [
{ tableNumber: "A1", capacity: 2, zone: "Front" },
{ tableNumber: "A2", capacity: 4, zone: "Front" },
{ tableNumber: "B1", capacity: 6, zone: "Garden" }
],
inventory: [
{ name: "Arabica beans", unit: "kg", costPerUnit: 240000, quantity: 8, reorderLevel: 5, supplierName: "GoodGo Supply" }
]
},
{
name: "GoodGo Restaurant MVP",
slug: "goodgo-restaurant-mvp",
vertical: "restaurant",
phone: "0900000001",
email: "restaurant@goodgo.vn",
description: "Restaurant fixture for dine-in, kitchen and payment workflows",
categories: ["Món chính", "Khai vị", "Đồ uống"],
products: [
{ name: "Cơm gà", price: 79000, category: "Món chính", sku: "RES-COM-GA", initialQuantity: 30 },
{ name: "Bò lúc lắc", price: 129000, category: "Món chính", sku: "RES-BO-LUC-LAC", initialQuantity: 18 },
{ name: "Gỏi cuốn", price: 49000, category: "Khai vị", sku: "RES-GOI-CUON", initialQuantity: 24 },
{ name: "Trà đá", price: 12000, category: "Đồ uống", sku: "RES-TRA-DA", initialQuantity: 60 }
],
tables: [
{ tableNumber: "R1", capacity: 4, zone: "Sảnh" },
{ tableNumber: "R2", capacity: 6, zone: "Sảnh" },
{ tableNumber: "VIP1", capacity: 10, zone: "VIP" }
],
inventory: [
{ name: "Gạo ST25", unit: "kg", costPerUnit: 32000, quantity: 50, reorderLevel: 10, supplierName: "GoodGo Supply" }
]
},
{
name: "GoodGo Karaoke MVP",
slug: "goodgo-karaoke-mvp",
vertical: "karaoke",
phone: "0900000002",
email: "karaoke@goodgo.vn",
description: "Karaoke fixture for room session and F&B workflows",
categories: ["Đồ uống", "Snack", "Combo"],
products: [
{ name: "Nước suối", price: 15000, category: "Đồ uống", sku: "KTV-NUOC-SUOI", initialQuantity: 100 },
{ name: "Bia lon", price: 35000, category: "Đồ uống", sku: "KTV-BIA-LON", initialQuantity: 80 },
{ name: "Khoai tây chiên", price: 69000, category: "Snack", sku: "KTV-KHOAI-TAY", initialQuantity: 25 },
{ name: "Combo phòng 2 giờ", price: 320000, category: "Combo", sku: "KTV-COMBO-2H", initialQuantity: 10 }
],
tables: [
{ tableNumber: "P101", capacity: 8, zone: "Tầng 1", hourlyRate: 180000 },
{ tableNumber: "P201", capacity: 12, zone: "Tầng 2", hourlyRate: 240000 },
{ tableNumber: "VIP3", capacity: 20, zone: "VIP", hourlyRate: 420000 }
],
inventory: [
{ name: "Ly nhựa", unit: "pcs", costPerUnit: 800, quantity: 500, reorderLevel: 100, supplierName: "GoodGo Supply" }
]
},
{
name: "GoodGo Spa MVP",
slug: "goodgo-spa-mvp",
vertical: "spa",
phone: "0900000003",
email: "spa@goodgo.vn",
description: "Spa fixture for appointment and therapist workflows",
categories: ["Liệu trình", "Chăm sóc da"],
products: [
{ name: "Massage body 60p", price: 450000, category: "Liệu trình", sku: "SPA-MASSAGE-60" },
{ name: "Facial cơ bản", price: 350000, category: "Chăm sóc da", sku: "SPA-FACIAL-BASIC" },
{ name: "Gội đầu dưỡng sinh", price: 220000, category: "Liệu trình", sku: "SPA-GOI-DAU" }
],
inventory: [
{ name: "Tinh dầu lavender", unit: "ml", costPerUnit: 1200, quantity: 1500, reorderLevel: 300, supplierName: "GoodGo Supply" }
]
},
{
name: "GoodGo Beauty MVP",
slug: "goodgo-beauty-mvp",
vertical: "beauty",
phone: "0900000004",
email: "beauty@goodgo.vn",
description: "Beauty fixture for salon service workflows",
categories: ["Tóc", "Nail"],
products: [
{ name: "Cắt tóc nữ", price: 180000, category: "Tóc", sku: "BEAUTY-CAT-TOC" },
{ name: "Uốn tóc", price: 850000, category: "Tóc", sku: "BEAUTY-UON-TOC" },
{ name: "Sơn gel", price: 250000, category: "Nail", sku: "BEAUTY-SON-GEL" }
],
inventory: [
{ name: "Dầu gội salon", unit: "ml", costPerUnit: 900, quantity: 2000, reorderLevel: 500, supplierName: "GoodGo Supply" }
]
},
{
name: "GoodGo Retail MVP",
slug: "goodgo-retail-mvp",
vertical: "retail",
phone: "0900000005",
email: "retail@goodgo.vn",
description: "Retail fixture for barcode, stock and return workflows",
categories: ["Hàng bán lẻ", "Combo"],
products: [
{ name: "Bình giữ nhiệt", price: 189000, category: "Hàng bán lẻ", sku: "RTL-BINH-GIU-NHIET", initialQuantity: 42 },
{ name: "Túi canvas", price: 99000, category: "Hàng bán lẻ", sku: "RTL-TUI-CANVAS", initialQuantity: 55 },
{ name: "Combo quà tặng", price: 259000, category: "Combo", sku: "RTL-COMBO-GIFT", initialQuantity: 20 }
],
inventory: [
{ name: "Tem barcode", unit: "pcs", costPerUnit: 120, quantity: 1000, reorderLevel: 200, supplierName: "GoodGo Supply" }
]
}
];
async function ensureShop(seed: ShopSeed) {
const existing = (await listShops()).find((shop) => shop.slug === seed.slug || shop.name === seed.name);
if (existing) {
const updated = await query(
`
UPDATE shops
SET slug = $2,
category_id = $3,
status_id = 2,
phone = $4,
email = $5,
description = $6,
features_config = COALESCE(features_config, '{}'::jsonb) || $7::jsonb,
updated_at = now()
WHERE id = $1::uuid
AND COALESCE(is_deleted, false) = false
AND NOT EXISTS (
SELECT 1 FROM shops other
WHERE other.slug = $2
AND other.id <> shops.id
AND COALESCE(other.is_deleted, false) = false
)
RETURNING id
`,
[
existing.id,
seed.slug,
verticalToCategoryId(seed.vertical),
seed.phone,
seed.email,
seed.description,
JSON.stringify({ vertical: seed.vertical, mvp: true })
]
);
if (!updated[0]) throw new Error(`Cannot normalize fixture shop slug: ${seed.slug}`);
}
const shop = existing ? (await listShops()).find((item) => item.id === existing.id) : await createShop({
name: seed.name,
slug: seed.slug,
vertical: seed.vertical,
phone: seed.phone,
email: seed.email,
description: seed.description,
statusId: 2
});
if (!shop) throw new Error(`Cannot seed shop: ${seed.slug}`);
return shop.statusId === 2 ? shop : await setShopStatus(shop.id, 2);
}
async function ensureCategories(shopId: string, names: string[]) {
const existing = await listCategories(shopId);
const byName = new Map(existing.map((category) => [category.name.toLowerCase(), category]));
for (const [index, name] of names.entries()) {
if (!byName.has(name.toLowerCase())) {
const created = await createCategory({ shopId, name, displayOrder: index + 1 });
byName.set(name.toLowerCase(), created);
}
}
return byName;
}
async function ensureProducts(shopId: string, vertical: Vertical, products: ProductSeed[], categories: Awaited<ReturnType<typeof ensureCategories>>) {
const existing = await listProducts(shopId);
const existingKeys = new Set(existing.flatMap((product) => [product.sku, product.name].filter(Boolean).map((value) => String(value).toLowerCase())));
for (const product of products) {
if (existingKeys.has(product.sku.toLowerCase()) || existingKeys.has(product.name.toLowerCase())) continue;
await createProduct({
shopId,
name: product.name,
price: product.price,
vertical,
categoryId: categories.get(product.category.toLowerCase())?.id,
sku: product.sku,
initialQuantity: product.initialQuantity ?? 0
});
}
}
async function ensureTables(shopId: string, tables: TableSeed[] = []) {
if (tables.length === 0) return;
const existing = await listTables(shopId);
const existingNumbers = new Set(existing.map((table) => table.tableNumber.toLowerCase()));
for (const table of tables) {
if (existingNumbers.has(table.tableNumber.toLowerCase())) continue;
await createTable({
shopId,
tableNumber: table.tableNumber,
capacity: table.capacity,
zone: table.zone,
hourlyRate: table.hourlyRate ?? 0
});
}
}
async function ensureInventory(shopId: string, inventory: InventorySeed[] = []) {
if (inventory.length === 0) return;
const existing = await listInventory(shopId);
const existingNames = new Set(existing.map((item) => String(item.name ?? "").toLowerCase()));
for (const item of inventory) {
if (existingNames.has(item.name.toLowerCase())) continue;
await createInventoryItem({
shopId,
name: item.name,
itemTypeId: 1,
unit: item.unit,
costPerUnit: item.costPerUnit,
quantity: item.quantity,
reorderLevel: item.reorderLevel,
supplierName: item.supplierName
});
}
}
for (const seed of shopSeeds) {
const shop = await ensureShop(seed);
const categories = await ensureCategories(shop.id, seed.categories);
await ensureProducts(shop.id, seed.vertical, seed.products, categories);
await ensureTables(shop.id, seed.tables);
await ensureInventory(shop.id, seed.inventory);
await seedParityData(shop.id);
console.log(`Seed completed for ${seed.vertical}: ${shop.name}`);
}
console.log("Full TPOS parity seed completed");
await getPool().end();