306 lines
11 KiB
TypeScript
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();
|