AdminUser held both panel staff and sales experts, so one mobile could not be both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser" untouched. Uniqueness is per table. - migration 20261026120000_split_admin_table: additive only. Creates "Admin", copies non-expert rows with their ids, re-points the "who did this" foreign keys (ledger, order status, sms, notification, upload, allocation, workshop, admin session) to "Admin" without moving data. Never writes to "AdminUser". - login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin"; SALES_EXPERT create/edit still goes through admin/users into "AdminUser". - an admin cannot be turned into an expert or the reverse (different tables). - manual/: optional phase-2 script to drop the dormant admin copies from "AdminUser", and a read-only expert-login diagnostic. - docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback. Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before and after; full migration chain applies and matches schema.prisma. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
780 lines
30 KiB
TypeScript
780 lines
30 KiB
TypeScript
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 ────────────────────────────────────────────────────────────
|
||
// 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: 'کارگاه مرکزی ساخت طلا' },
|
||
});
|
||
|
||
// ── Admins (table `Admin`) and sales agents (table `AdminUser`) ─────────────────────────
|
||
|
||
// 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.admin.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.admin.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.admin.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.admin.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: 'سکه و شمش' } }),
|
||
]);
|
||
|
||
// 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({
|
||
where: { id: 'brand-montzar' },
|
||
update: { title: 'تهران تاج طلا' },
|
||
create: { id: 'brand-montzar', 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: brandMain.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: brandMain.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: brandMain.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();
|
||
});
|