Files
hedena_gold/apps/api/prisma/seed.ts
T
s.esmaeiliandClaude Opus 5.5 1c850216e9 fix(security): no dev key, demo seed or open CORS in production (SEC-04, SEC-05)
- production refuses to boot without JWT_SECRET instead of signing
  admin tokens with the published development key
- CORS_ORIGINS restricts browser callers; blank keeps allow-all with a
  production warning so a running deployment is not cut off
- the boot-time seed creates only roles and permissions in production;
  the demo customers, orders, debt and known-password accounts need
  SEED_DEMO_DATA=true

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:00:45 +03:30

767 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AdminRole,
Customer,
CustomerKind,
OrderChannel,
PrismaClient,
VerificationStep,
} from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { ALL_RIDS } from '../src/modules/auth/acl.constants';
const prisma = new PrismaClient();
// Real bcrypt hash of DEV_PASSWORD, so seeded accounts can actually log in.
// Development credentials only — never seed these into a real deployment.
const DEV_PASSWORD = 'admin1234';
const PLACEHOLDER_PASSWORD_HASH = bcrypt.hashSync(DEV_PASSWORD, 10);
// A single number authorized to sign in to BOTH apps for local development,
// with its password (admin panel) equal to the number itself. DEV-ONLY: this
// file is never run against a real deployment — only by hand via
// `pnpm prisma:seed` / `npx prisma db seed` against a local database.
const DEV_TEST_PHONE = '09128081392';
const DEV_TEST_PHONE_PASSWORD_HASH = bcrypt.hashSync(DEV_TEST_PHONE, 10);
// Served from the user-app's own public/ folder. This is the same placeholder
// image the legacy app used for missing/failed product imagery (no-items.png).
// Replace these rows with real photo URLs when product photography exists.
const PLACEHOLDER_PRODUCT_IMAGE = '/img/no-items.png';
/**
* Role definitions and their permissions — the one part of this file a real
* deployment needs (the admin screens and the legacy import map onto these
* ids). Idempotent: upserts and skipDuplicates only.
*/
async function seedAccessControl() {
// ── Admin user types + permissions (legacy `permission(rid, ut)`) ────
//
// In legacy these lived in the dynamic `config_static` lookup under the key
// `__list__ut` and were created from the UI (views/user/Permission.vue), so
// there is no legacy seeder to copy. Id 85 is seeded because Permission.vue
// hardcodes it as the default selection (`setFieldsValue({ut:"85"})`).
//
// Only leaf rids are stored; ancestors are derived at runtime by
// expandWithParents() / checkParent(), exactly as legacy did.
const userTypes: { id: number; title: string; rids: readonly string[] }[] = [
// Full access — every rid the routers gate.
{ id: 85, title: 'مدیر ارشد', rids: ALL_RIDS },
{
id: 86,
title: 'کارشناس فروش',
rids: [
'order.list', 'order.create', 'order.omde', 'order.makeOrder',
'persons.list.client.omde', 'persons.list.client.omde.view',
'persons.list.client.single', 'persons.list.client.single.view',
'persons.create', 'product.list', 'dashboard.view', 'comment',
],
},
{
id: 87,
title: 'حسابدار',
rids: [
'document.list', 'document.create', 'document.list.edit',
'invoice.list', 'invoice.create', 'wallet', 'config.wallet',
'report.order.item', 'report.product', 'config.report', 'dashboard.view',
],
},
{
id: 88,
title: 'سرپرست کارگاه',
rids: ['workshop.list', 'workshop.create', 'order.tamin', 'order.list', 'product.list'],
},
{
id: 89,
title: 'کاربر شعبه',
rids: ['buy.order.list', 'order.create', 'persons.create', 'branch.list'],
},
];
for (const ut of userTypes) {
await prisma.userType.upsert({
where: { id: ut.id },
update: { title: ut.title },
create: { id: ut.id, title: ut.title },
});
await prisma.permission.createMany({
data: ut.rids.map((rid) => ({ rid, userTypeId: ut.id })),
skipDuplicates: true,
});
}
}
/**
* Everything below is DEMO data: sample cities, branches, accounts with known
* passwords (one super admin whose password is its own phone number), sample
* customers, products, orders and posted debt. The app runs this seed once on
* a fresh database at boot (src/prisma/bootstrap.ts), so without this gate a
* production first boot would mix fake customers, fake debt and open admin
* accounts into the real data (SEC-05). Production never gets it unless
* SEED_DEMO_DATA=true is set on purpose.
*/
function demoDataAllowed(): boolean {
if (process.env.SEED_DEMO_DATA?.trim() === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
async function main() {
await seedAccessControl();
if (!demoDataAllowed()) {
console.log('seed: production — roles and permissions only; demo data skipped (set SEED_DEMO_DATA=true to force)');
return;
}
// ── Cities ────────────────────────────────────────────────────────────
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
prisma.city.upsert({ where: { id: 'city-tehran' }, update: {}, create: { id: 'city-tehran', name: 'تهران', province: 'تهران' } }),
prisma.city.upsert({ where: { id: 'city-mashhad' }, update: {}, create: { id: 'city-mashhad', name: 'مشهد', province: 'خراسان رضوی' } }),
prisma.city.upsert({ where: { id: 'city-isfahan' }, update: {}, create: { id: 'city-isfahan', name: 'اصفهان', province: 'اصفهان' } }),
prisma.city.upsert({ where: { id: 'city-tabriz' }, update: {}, create: { id: 'city-tabriz', name: 'تبریز', province: 'آذربایجان شرقی' } }),
]);
// ── Branches & workshop ──────────────────────────────────────────────
const branchTehran = await prisma.branch.upsert({
where: { id: 'branch-tehran-bazaar' },
update: {},
create: { id: 'branch-tehran-bazaar', name: 'شعبه بازار بزرگ تهران', cityId: tehran.id },
});
const branchMashhad = await prisma.branch.upsert({
where: { id: 'branch-mashhad-main' },
update: {},
create: { id: 'branch-mashhad-main', name: 'شعبه مرکزی مشهد', cityId: mashhad.id },
});
await prisma.workshop.upsert({
where: { id: 'workshop-central' },
update: {},
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
});
// ── Admin users (sales agents + one super admin) ────────────────────
// DEV-ONLY test login, per explicit request: authorized in BOTH the admin
// panel and the user-app, password = the phone number itself. Super admin
// so it can reach every screen while testing. Do not carry this into a real
// deployment's seed data.
await prisma.adminUser.upsert({
where: { mobile: DEV_TEST_PHONE },
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
create: {
mobile: DEV_TEST_PHONE,
fullName: 'کاربر تست توسعه',
passwordHash: DEV_TEST_PHONE_PASSWORD_HASH,
role: AdminRole.SUPER_ADMIN,
userTypeId: 85,
branchId: null,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09120000001' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
create: {
mobile: '09120000001',
fullName: 'مدیر ارشد سیستم',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SUPER_ADMIN,
userTypeId: 85,
// Head office: deliberately branch-less. Legacy scoped every list by
// branch_id whenever the user had one, so head-office staff must have none
// or they would see only a single branch's data.
branchId: null,
},
});
const agentReza = await prisma.adminUser.upsert({
where: { mobile: '09121234567' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
create: {
mobile: '09121234567',
fullName: 'رضا احمدی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
branchId: branchTehran.id,
wagePercent: 1.5,
},
});
const agentSara = await prisma.adminUser.upsert({
where: { mobile: '09129876543' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
create: {
mobile: '09129876543',
fullName: 'سارا کریمی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
branchId: branchMashhad.id,
wagePercent: 1.2,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09123334444' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
create: {
mobile: '09123334444',
fullName: 'هومن یزدانی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.WORKSHOP_SUPERVISOR,
userTypeId: 88,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09125556666' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
create: {
mobile: '09125556666',
fullName: 'فرزانه محمودی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.ACCOUNTANT,
userTypeId: 87,
branchId: null,
},
});
// ── Customers ────────────────────────────────────────────────────────
const customerData = [
{
// DEV-ONLY test login, per explicit request: same number as the admin
// panel account above, so it can also sign in to the user-app (OTP step
// matches on mobile against a real seeded customer — see LoginView.vue).
// Fully verified so no onboarding step blocks it. Not for a real deployment.
id: 'customer-dev-test',
kind: CustomerKind.APP_SHOPKEEPER,
memberCode: 'DEV-TEST',
fullName: 'کاربر تست توسعه',
mobile: DEV_TEST_PHONE,
nationalCode: '0000000000',
cityId: tehran.id,
referredByAgentId: null,
goldCreditLimitGrams: '1000',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-1',
kind: CustomerKind.BANKER,
storeName: 'طلای محمدی', pasazhName: 'پاساژ بزرگ', zone: 'بازار', memberCode: 'M-1001', phone: '02133445566', address: 'تهران، بازار بزرگ، پلاک ۱۲',
fullName: 'علی محمدی',
mobile: '09151112233',
nationalCode: '0012345678',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '200',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-2',
kind: CustomerKind.APP_SHOPKEEPER,
storeName: 'گالری حسینی', zone: 'ونک', memberCode: 'M-1002', phone: '02188776655',
fullName: 'مریم حسینی',
mobile: '09151112234',
nationalCode: '0012345679',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '100',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-3',
kind: CustomerKind.WALK_IN_SHOPKEEPER,
storeName: 'زرگری نوروزی', pasazhName: 'پاساژ رضا', zone: 'طرق', memberCode: 'M-1003',
fullName: 'جواد نوروزی',
mobile: '09151112235',
nationalCode: '0012345680',
cityId: mashhad.id,
referredByAgentId: agentSara.id,
goldCreditLimitGrams: '50',
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
},
{
id: 'customer-4',
kind: CustomerKind.APP_SHOPKEEPER,
memberCode: 'M-1004',
fullName: 'نرگس ابراهیمی',
mobile: '09151112236',
nationalCode: '0012345681',
cityId: isfahan.id,
referredByAgentId: null,
goldCreditLimitGrams: '30',
verificationStep: VerificationStep.UNVERIFIED,
// archived, so the "لیست بایگانی" grid has a row and the type lists
// correctly exclude it
isArchived: true,
},
{
id: 'customer-5',
kind: CustomerKind.BANKER,
storeName: 'صادقی و پسران', zone: 'بازار', memberCode: 'M-1005', phone: '02155443322',
fullName: 'کامران صادقی',
mobile: '09151112237',
nationalCode: '0012345682',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '500',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-6',
kind: CustomerKind.WALK_IN_SHOPKEEPER,
storeName: 'طلاسرای قاسمی', zone: 'ائل گلی', memberCode: 'M-1006',
fullName: 'زهرا قاسمی',
mobile: '09151112238',
nationalCode: '0012345683',
cityId: tabriz.id,
referredByAgentId: agentSara.id,
goldCreditLimitGrams: '80',
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
},
];
const customers: Customer[] = [];
for (const c of customerData) {
customers.push(
await prisma.customer.upsert({
where: { id: c.id },
// Re-assert the DESCRIPTIVE fields on an existing row so a re-seed
// backfills columns added after that row was first created. Balances,
// verification state and credit limits are deliberately NOT touched —
// those are live data a re-seed must never overwrite.
update: {
kind: c.kind,
phone: 'phone' in c ? c.phone : null,
address: 'address' in c ? c.address : null,
storeName: 'storeName' in c ? c.storeName : null,
pasazhName: 'pasazhName' in c ? c.pasazhName : null,
zone: 'zone' in c ? c.zone : null,
memberCode: c.memberCode,
isArchived: 'isArchived' in c ? c.isArchived : false,
},
create: {
id: c.id,
fullName: c.fullName,
mobile: c.mobile,
nationalCode: c.nationalCode,
passwordHash: PLACEHOLDER_PASSWORD_HASH,
cityId: c.cityId,
referredByAgentId: c.referredByAgentId,
goldCreditLimitGrams: c.goldCreditLimitGrams,
verificationStep: c.verificationStep,
kind: c.kind,
phone: 'phone' in c ? c.phone : null,
address: 'address' in c ? c.address : null,
storeName: 'storeName' in c ? c.storeName : null,
pasazhName: 'pasazhName' in c ? c.pasazhName : null,
zone: 'zone' in c ? c.zone : null,
memberCode: c.memberCode,
isArchived: 'isArchived' in c ? c.isArchived : false,
},
}),
);
}
// Looked up by id rather than array position — a positional destructure here
// silently breaks (wrong customer, no error) the moment a row is added or
// reordered above, as happened when the dev-test login row was inserted first.
const byId = (id: string) => customers.find((c) => c.id === id)!;
const customer1 = byId('customer-1');
const customer2 = byId('customer-2');
const customer5 = byId('customer-5');
// ── Catalog: categories, brands, products, variants ────────────────
const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([
prisma.category.upsert({ where: { id: 'cat-rings' }, update: {}, create: { id: 'cat-rings', title: 'انگشتر' } }),
prisma.category.upsert({ where: { id: 'cat-bracelets' }, update: {}, create: { id: 'cat-bracelets', title: 'دستبند' } }),
prisma.category.upsert({ where: { id: 'cat-necklaces' }, update: {}, create: { id: 'cat-necklaces', title: 'گردنبند' } }),
prisma.category.upsert({ where: { id: 'cat-earrings' }, update: {}, create: { id: 'cat-earrings', title: 'گوشواره' } }),
prisma.category.upsert({ where: { id: 'cat-coins' }, update: {}, create: { id: 'cat-coins', title: 'سکه و شمش' } }),
]);
const brandMontzar = await prisma.brand.upsert({
where: { id: 'brand-henza' },
update: { title: 'منتزر طلا' },
create: { id: 'brand-henza', title: 'منتزر طلا' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
update: {},
create: { id: 'brand-artisan', title: 'مجموعه صنعتگر' },
});
const productDefs = [
{
id: 'product-classic-ring',
title: 'انگشتر تک نگین کلاسیک',
description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.',
categoryId: ringsCategory.id,
brandId: brandMontzar.id,
variants: [
{ id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' },
{ id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' },
],
},
{
id: 'product-woven-bracelet',
title: 'دستبند بافت زنجیری',
description: 'دستبند بافت‌دار دست‌ساز از طلای ۱۸ عیار.',
categoryId: braceletsCategory.id,
brandId: brandArtisan.id,
variants: [
{ id: 'variant-bracelet-standard', title: 'استاندارد ۱۸ سانتی', sku: 'BRC-WOVEN-18', rashWeightGrams: '14.200', defaultWagePercent: '11.5' },
],
},
{
id: 'product-pendant-necklace',
title: 'گردنبند آویز اشکی',
description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.',
categoryId: necklacesCategory.id,
brandId: brandMontzar.id,
variants: [
{ id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' },
{ id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' },
],
},
{
id: 'product-hoop-earrings',
title: 'گوشواره حلقه‌ای کلاسیک',
description: 'گوشواره حلقه‌ای طلای ۱۸ عیار مناسب استفاده روزمره.',
categoryId: earringsCategory.id,
brandId: brandArtisan.id,
variants: [
{ id: 'variant-hoop-small', title: 'کوچک', sku: 'ERG-HOOP-S', rashWeightGrams: '1.900', defaultWagePercent: '8.5' },
{ id: 'variant-hoop-large', title: 'بزرگ', sku: 'ERG-HOOP-L', rashWeightGrams: '3.400', defaultWagePercent: '8.5' },
],
},
{
id: 'product-full-coin',
title: 'سکه تمام بهار آزادی',
description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.',
categoryId: coinsCategory.id,
brandId: brandMontzar.id,
variants: [
{ id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' },
{ id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' },
],
},
];
const createdVariantIds: Record<string, string> = {};
let imageCount = 0;
for (const p of productDefs) {
const product = await prisma.product.upsert({
where: { id: p.id },
update: {},
create: {
id: p.id,
title: p.title,
description: p.description,
categoryId: p.categoryId,
brandId: p.brandId,
},
});
for (const v of p.variants) {
await prisma.productVariant.upsert({
where: { id: v.id },
update: {},
create: {
id: v.id,
productId: product.id,
title: v.title,
sku: v.sku,
rashWeightGrams: v.rashWeightGrams,
defaultWagePercent: v.defaultWagePercent,
},
});
createdVariantIds[v.id] = v.id;
}
// Product photography does not exist in this project: legacy served it from
// the old Laravel host (Product.thumb.full), so there is nothing to migrate.
// Seed the placeholder the legacy app itself used for missing imagery so
// every product card renders an image instead of collapsing to nothing.
await prisma.productImage.upsert({
where: { id: `${p.id}-img-1` },
update: { url: PLACEHOLDER_PRODUCT_IMAGE },
create: {
id: `${p.id}-img-1`,
productId: product.id,
url: PLACEHOLDER_PRODUCT_IMAGE,
sortOrder: 0,
},
});
imageCount += 1;
}
// ── Featured collection ──────────────────────────────────────────────
const featured = await prisma.collection.upsert({
where: { id: 'collection-featured' },
update: {},
create: { id: 'collection-featured', title: 'پرفروش‌های این هفته', description: 'منتخبی از محبوب‌ترین محصولات فروشگاه.' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-classic-ring' } },
update: {},
create: { collectionId: featured.id, productId: 'product-classic-ring' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-pendant-necklace' } },
update: {},
create: { collectionId: featured.id, productId: 'product-pendant-necklace' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-full-coin' } },
update: {},
create: { collectionId: featured.id, productId: 'product-full-coin' },
});
// ── Gold price quote (illustrative, not live-market-accurate) ───────
await prisma.goldPriceQuote.create({
data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' },
});
// A RETAIL order, so the single-sale grid (legacy `POST single-order`) has a
// row of its own. Legacy kept the two channels in separate tables; here they
// are separated by Order.channel.
const existingRetail = await prisma.order.findUnique({
where: { orderNumber: 'ORD-SEED-0002' },
});
if (!existingRetail) {
const rash = '2.400';
const wage = 7.5;
const computed = (parseFloat(rash) * (1 + wage / 100)).toFixed(4);
await prisma.order.create({
data: {
orderNumber: 'ORD-SEED-0002',
customerId: customer2.id,
branchId: branchTehran.id,
channel: OrderChannel.RETAIL,
status: 'PLACED',
totalGoldWeightGrams: computed,
wageWeightGrams: (parseFloat(computed) - parseFloat(rash)).toFixed(4),
flatRialCharge: '0',
items: {
create: [
{
productVariantId: 'variant-ring-size-7',
quantity: 1,
rashWeightGramsSnapshot: rash,
wagePercentSnapshot: wage.toString(),
discountPercentSnapshot: '0',
computedWeightGrams: computed,
flatRialCharge: '0',
},
],
},
statusEvents: { create: [{ toStatus: 'PLACED' }] },
},
});
}
// Order.channel was added after these demo rows first existed, so a re-seed
// corrects the channel on the two named seed orders. Scoped to those exact
// order numbers — it must never rewrite real orders.
await prisma.order.updateMany({
where: { orderNumber: 'ORD-SEED-0001' },
data: { channel: OrderChannel.WHOLESALE },
});
await prisma.order.updateMany({
where: { orderNumber: 'ORD-SEED-0002' },
data: { channel: OrderChannel.RETAIL },
});
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } });
if (!existingDemoOrder) {
const rashWeight = '3.100'; // variant-ring-size-7
const wagePercent = 9.0;
const discountPercent = 0;
const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4);
const quantity = 1;
const flatRialChargePerUnit = '500000'; // e.g. a small stone-setting fee
const order = await prisma.order.create({
data: {
orderNumber: 'ORD-SEED-0001',
customerId: customer1.id,
branchId: branchTehran.id,
// customer1 is a بنکدار, so this belongs to the wholesale grid
channel: OrderChannel.WHOLESALE,
status: 'DELIVERED_TO_CUSTOMER',
totalGoldWeightGrams: computedWeight,
wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4),
flatRialCharge: flatRialChargePerUnit,
deliveredAt: new Date(),
items: {
create: [
{
productVariantId: 'variant-ring-size-7',
quantity,
rashWeightGramsSnapshot: rashWeight,
wagePercentSnapshot: wagePercent.toString(),
discountPercentSnapshot: discountPercent.toString(),
computedWeightGrams: computedWeight,
flatRialCharge: flatRialChargePerUnit,
},
],
},
statusEvents: {
create: [{ toStatus: 'PLACED' }, { toStatus: 'ADMIN_APPROVED' }, { toStatus: 'DELIVERED_TO_CUSTOMER' }],
},
},
});
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'GOLD_GRAM',
direction: 'DEBIT',
sourceType: 'ORDER_STOCK',
amount: computedWeight,
orderId: order.id,
confirmed: true,
confirmedAt: new Date(),
note: 'بدهی وزنی طلا هنگام تحویل (داده آزمایشی)',
},
});
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'RIAL',
direction: 'DEBIT',
sourceType: 'ORDER_STOCK',
amount: flatRialChargePerUnit,
orderId: order.id,
confirmed: true,
confirmedAt: new Date(),
note: 'هزینه ثابت ریالی هنگام تحویل (داده آزمایشی)',
},
});
await prisma.customer.update({
where: { id: customer1.id },
data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } },
});
// A pending repayment awaiting admin confirmation, to demo the confirm flow.
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'GOLD_GRAM',
direction: 'CREDIT',
sourceType: 'REPAYMENT_MELTED_GOLD',
amount: '1.5000',
karatPerMille: 750,
confirmed: false,
note: 'تسویه با طلای آب‌شده، در انتظار تایید (داده آزمایشی)',
},
});
}
// customer2 and customer5 get existing debt/credit variety for the dashboard.
await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } });
await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } });
// ── Pre-orders (legacy "سفارش های ساختنی" / custom orders) ──────────
// Design requests an admin prices and later converts into a real Order.
// Seeded so the MakeOrder grid has rows to render.
const preOrderSeeds = [
{
id: 'preorder-0001',
customerId: customer2.id,
cityId: tehran.id,
status: 'PLACED' as const,
colorName: 'رزگلد',
sizeSpec: '۱۶',
stoneColor: 'سفید',
minaColor: 'آبی',
description: 'انگشتر طرح سنتی با نگین سفید',
estimatedWeightGrams: '4.5000',
},
{
id: 'preorder-0002',
customerId: customer5.id,
cityId: mashhad.id,
status: 'ADMIN_APPROVED' as const,
colorName: 'زرد',
sizeSpec: '۱۸',
stoneColor: 'قرمز',
minaColor: 'سبز',
description: 'دستبند مینا کاری',
estimatedWeightGrams: '9.2500',
},
];
for (const po of preOrderSeeds) {
await prisma.preOrder.upsert({
where: { id: po.id },
update: {},
create: po,
});
}
// ── Product comments (legacy `comment` table) ───────────────────────
// Seeded across all three moderation states so the Comments grid and its
// status filter have something to show.
const allProducts = await prisma.product.findMany({ take: 3 });
if (allProducts.length > 0) {
const commentSeeds = [
{
id: 'comment-0001',
productId: allProducts[0].id,
customerId: customer2.id,
body: 'کیفیت ساخت عالی بود، ممنون از ارسال سریع.',
status: 'APPROVED' as const,
},
{
id: 'comment-0002',
productId: allProducts[0].id,
customerId: customer5.id,
body: 'آیا این مدل در سایز بزرگ‌تر هم موجود می‌شود؟',
status: 'PENDING' as const,
},
{
id: 'comment-0003',
productId: allProducts[Math.min(1, allProducts.length - 1)].id,
customerId: customer2.id,
body: 'وزن با آنچه نوشته شده کمی تفاوت داشت.',
status: 'REJECTED' as const,
},
];
for (const c of commentSeeds) {
await prisma.comment.upsert({ where: { id: c.id }, update: {}, create: c });
}
}
// ── Variant stock + product codes (columns added in Batch 6) ────────
// Without stock every variant reads as out-of-stock, which the cart guard
// rejects, so the order builder would have nothing to add.
const allVariants = await prisma.productVariant.findMany();
for (const [i, v] of allVariants.entries()) {
await prisma.productVariant.update({
where: { id: v.id },
data: { qty: 5 + (i % 4), isPreOrder: i % 5 === 0 },
});
}
const codedProducts = await prisma.product.findMany({ orderBy: { title: 'asc' } });
for (const [i, p] of codedProducts.entries()) {
await prisma.product.update({
where: { id: p.id },
data: { productCode: `P-${String(i + 1).padStart(4, '0')}` },
});
}
console.log('Seed complete:', {
cities: 4,
branches: 2,
adminUsers: 5,
customers: customers.length,
products: productDefs.length,
variants: Object.keys(createdVariantIds).length,
productImages: imageCount,
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});