Files

780 lines
30 KiB
TypeScript
Raw Permalink Normal View History

import {
AdminRole,
Customer,
CustomerKind,
OrderChannel,
PrismaClient,
VerificationStep,
} from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { ALL_RIDS } from '../src/modules/auth/acl.constants';
2026-09-09 06:46:27 +03:30
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);
2026-09-09 06:46:27 +03:30
// 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',
2026-09-19 12:42:17 +03:30
'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',
2026-09-19 12:42:17 +03:30
'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 ────────────────────────────────────────────────────────────
// A tree, as legacy's: country > province > city. The country is a heading
// in the admin pickers; provinces and cities are selectable.
const city = (id: string, name: string, parentId: string | null) =>
prisma.city.upsert({ where: { id }, update: {}, create: { id, name, parentId } });
await city('city-iran', 'ایران', null);
await Promise.all([
city('city-prov-tehran', 'استان تهران', 'city-iran'),
city('city-prov-khorasan-razavi', 'استان خراسان رضوی', 'city-iran'),
city('city-prov-isfahan', 'استان اصفهان', 'city-iran'),
city('city-prov-east-azarbaijan', 'استان آذربایجان شرقی', 'city-iran'),
]);
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
city('city-tehran', 'تهران', 'city-prov-tehran'),
city('city-mashhad', 'مشهد', 'city-prov-khorasan-razavi'),
city('city-isfahan', 'اصفهان', 'city-prov-isfahan'),
city('city-tabriz', 'تبریز', 'city-prov-east-azarbaijan'),
]);
// ── 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: 'کارگاه مرکزی ساخت طلا' },
});
2026-09-09 06:46:27 +03:30
// ── 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,
},
});
2026-09-09 06:46:27 +03:30
await prisma.adminUser.upsert({
where: { mobile: '09120000001' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
2026-09-09 06:46:27 +03:30
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,
2026-09-09 06:46:27 +03:30
},
});
const agentReza = await prisma.adminUser.upsert({
where: { mobile: '09121234567' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
2026-09-09 06:46:27 +03:30
create: {
mobile: '09121234567',
fullName: 'رضا احمدی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
2026-09-09 06:46:27 +03:30
branchId: branchTehran.id,
wagePercent: 1.5,
},
});
const agentSara = await prisma.adminUser.upsert({
where: { mobile: '09129876543' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
2026-09-09 06:46:27 +03:30
create: {
mobile: '09129876543',
fullName: 'سارا کریمی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
2026-09-09 06:46:27 +03:30
branchId: branchMashhad.id,
wagePercent: 1.2,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09123334444' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
2026-09-09 06:46:27 +03:30
create: {
mobile: '09123334444',
fullName: 'هومن یزدانی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.WORKSHOP_SUPERVISOR,
userTypeId: 88,
2026-09-09 06:46:27 +03:30
},
});
await prisma.adminUser.upsert({
where: { mobile: '09125556666' },
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
2026-09-09 06:46:27 +03:30
create: {
mobile: '09125556666',
fullName: 'فرزانه محمودی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.ACCOUNTANT,
userTypeId: 87,
branchId: null,
2026-09-09 06:46:27 +03:30
},
});
// ── 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,
},
2026-09-09 06:46:27 +03:30
{
id: 'customer-1',
kind: CustomerKind.BANKER,
storeName: 'طلای محمدی', pasazhName: 'پاساژ بزرگ', zone: 'بازار', memberCode: 'M-1001', phone: '02133445566', address: 'تهران، بازار بزرگ، پلاک ۱۲',
2026-09-09 06:46:27 +03:30
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',
2026-09-09 06:46:27 +03:30
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',
2026-09-09 06:46:27 +03:30
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',
2026-09-09 06:46:27 +03:30
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,
2026-09-09 06:46:27 +03:30
},
{
id: 'customer-5',
kind: CustomerKind.BANKER,
storeName: 'صادقی و پسران', zone: 'بازار', memberCode: 'M-1005', phone: '02155443322',
2026-09-09 06:46:27 +03:30
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',
2026-09-09 06:46:27 +03:30
fullName: 'زهرا قاسمی',
mobile: '09151112238',
nationalCode: '0012345683',
cityId: tabriz.id,
referredByAgentId: agentSara.id,
goldCreditLimitGrams: '80',
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
},
];
const customers: Customer[] = [];
2026-09-09 06:46:27 +03:30
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,
},
2026-09-09 06:46:27 +03:30
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,
2026-09-09 06:46:27 +03:30
},
}),
);
}
// 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');
2026-09-09 06:46:27 +03:30
// ── 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: 'سکه و شمش' } }),
]);
2026-10-10 17:17:14 +03:30
// Databases seeded before the rename hold the old id; Product/Promotion FKs cascade on update.
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } });
const brandMain = await prisma.brand.upsert({
2026-10-10 17:17:14 +03:30
where: { id: 'brand-montzar' },
update: { title: 'تهران تاج طلا' },
create: { id: 'brand-montzar', title: 'تهران تاج طلا' },
2026-09-09 06:46:27 +03:30
});
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: brandMain.id,
2026-09-09 06:46:27 +03:30
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: brandMain.id,
2026-09-09 06:46:27 +03:30
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: brandMain.id,
2026-09-09 06:46:27 +03:30
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 },
});
2026-09-09 06:46:27 +03:30
// ── 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,
2026-09-09 06:46:27 +03:30
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')}` },
});
}
2026-09-09 06:46:27 +03:30
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();
});