feat: initial commit
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
// Gold Banking System — core schema
|
||||
// Canonical accounting model: legacy "System A" gram ledger (see migration notes).
|
||||
// The external "Tahesab" service is retired — LedgerEntry is the sole source of truth.
|
||||
// All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Enums
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AdminRole {
|
||||
SUPER_ADMIN
|
||||
SYSTEM_APPROVER // legacy: تایید مدیر سیستم
|
||||
SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent"
|
||||
WORKSHOP_SUPERVISOR
|
||||
ACCOUNTANT
|
||||
}
|
||||
|
||||
enum VerificationStep {
|
||||
UNVERIFIED
|
||||
STEP_ONE_SUBMITTED
|
||||
STEP_TWO_SUBMITTED
|
||||
VERIFIED
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement
|
||||
PLACED
|
||||
ADMIN_APPROVED
|
||||
SOURCING
|
||||
PREPARING
|
||||
DELIVERED_TO_AGENT
|
||||
DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PreOrderStatus {
|
||||
PLACED
|
||||
ADMIN_APPROVED
|
||||
WORKSHOP_APPROVED
|
||||
READY_TO_SHIP
|
||||
DELIVERED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum LedgerCurrency {
|
||||
GOLD_GRAM
|
||||
RIAL
|
||||
}
|
||||
|
||||
enum LedgerDirection {
|
||||
DEBIT // increases what the customer owes
|
||||
CREDIT // decreases what the customer owes
|
||||
}
|
||||
|
||||
enum LedgerSourceType {
|
||||
ORDER_STOCK
|
||||
ORDER_PRE_ORDER
|
||||
REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750
|
||||
REPAYMENT_TRANSFER
|
||||
REPAYMENT_CHEQUE
|
||||
REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry
|
||||
WALLET_BONUS
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Identity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
mobile String @unique
|
||||
passwordHash String
|
||||
role AdminRole
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
postedLedgerEntries LedgerEntry[]
|
||||
orderStatusEvents OrderStatusEvent[]
|
||||
preOrderStatusEvents PreOrderStatusEvent[]
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
mobile String @unique
|
||||
nationalCode String @unique
|
||||
passwordHash String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
referredByAgentId String?
|
||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
verificationStep VerificationStep @default(UNVERIFIED)
|
||||
nationalIdImageUrl String?
|
||||
businessLicenseUrl String?
|
||||
shopImageUrl String?
|
||||
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
addresses CustomerAddress[]
|
||||
orders Order[]
|
||||
preOrders PreOrder[]
|
||||
ledgerEntries LedgerEntry[]
|
||||
walletBonuses WalletBonus[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
}
|
||||
|
||||
model CustomerAddress {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
addressLine String
|
||||
isDefault Boolean @default(false)
|
||||
}
|
||||
|
||||
model Branch {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
adminUsers AdminUser[]
|
||||
orders Order[]
|
||||
}
|
||||
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Catalog — item reference catalog
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryTree")
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Brand {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
products Product[]
|
||||
}
|
||||
|
||||
/// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging
|
||||
/// feature, NOT a gold-price snapshot mechanism, despite the misleading legacy name).
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
products CollectionProduct[]
|
||||
}
|
||||
|
||||
model CollectionProduct {
|
||||
collectionId String
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
@@id([collectionId, productId])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
brandId String?
|
||||
brand Brand? @relation(fields: [brandId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
url String
|
||||
sortOrder Int @default(0)
|
||||
}
|
||||
|
||||
model ProductVariant {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
title String
|
||||
sku String? @unique
|
||||
/// Base/raw weight before making charge ("rash weight").
|
||||
rashWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Default making-charge percentage applied on top of rash weight.
|
||||
defaultWagePercent Decimal @db.Decimal(6, 3)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
orderItems OrderItem[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Orders — stock orders (debt-bearing)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
status OrderStatus @default(PLACED)
|
||||
|
||||
/// Total gold weight incl. wage across all items ("allGram" / order.w).
|
||||
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum).
|
||||
wageWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Flat Rial charge independent of gold price (e.g. stones) — legacy rial_input.
|
||||
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
|
||||
|
||||
isSuspendedOverCreditLimit Boolean @default(false)
|
||||
deliveredAt DateTime? // setting this is what triggers debt recognition
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items OrderItem[]
|
||||
statusEvents OrderStatusEvent[]
|
||||
ledgerEntries LedgerEntry[]
|
||||
convertedFromPreOrder PreOrder?
|
||||
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
productVariantId String
|
||||
productVariant ProductVariant @relation(fields: [productVariantId], references: [id])
|
||||
quantity Int
|
||||
|
||||
// Snapshots at order time — never re-read from ProductVariant after placement.
|
||||
rashWeightGramsSnapshot Decimal @db.Decimal(14, 4)
|
||||
wagePercentSnapshot Decimal @db.Decimal(6, 3)
|
||||
discountPercentSnapshot Decimal @default(0) @db.Decimal(6, 3)
|
||||
/// computed: rash*(1 + (wagePercent-discount)/100)
|
||||
computedWeightGrams Decimal @db.Decimal(14, 4)
|
||||
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
|
||||
}
|
||||
|
||||
model OrderStatusEvent {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
fromStatus OrderStatus?
|
||||
toStatus OrderStatus
|
||||
note String?
|
||||
actorAdminUserId String?
|
||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orderId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Pre-orders — custom manufacturing requests (not debt-bearing until converted)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model PreOrder {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
workshopId String?
|
||||
workshop Workshop? @relation(fields: [workshopId], references: [id])
|
||||
status PreOrderStatus @default(PLACED)
|
||||
|
||||
designImageUrl String?
|
||||
colorName String?
|
||||
sizeSpec String?
|
||||
stoneColor String?
|
||||
minaColor String?
|
||||
estimatedWeightGrams Decimal? @db.Decimal(14, 4)
|
||||
estimatedPriceRial Decimal? @db.Decimal(20, 2)
|
||||
description String?
|
||||
|
||||
/// Once priced, an admin converts this into a real (debt-bearing) Order.
|
||||
convertedOrderId String? @unique
|
||||
convertedOrder Order? @relation(fields: [convertedOrderId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
statusEvents PreOrderStatusEvent[]
|
||||
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
model PreOrderStatusEvent {
|
||||
id String @id @default(cuid())
|
||||
preOrderId String
|
||||
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
||||
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
||||
changes Json
|
||||
actorAdminUserId String?
|
||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([preOrderId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting — the dual-currency ledger (core of the system)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model LedgerEntry {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
|
||||
currency LedgerCurrency
|
||||
direction LedgerDirection
|
||||
sourceType LedgerSourceType
|
||||
/// Always positive; direction determines the sign applied to the customer balance.
|
||||
amount Decimal @db.Decimal(20, 4)
|
||||
|
||||
/// Purity of the physical instrument, for REPAYMENT_MELTED_GOLD (e.g. 750, 916).
|
||||
karatPerMille Int?
|
||||
/// Gold price/gram at conversion time, for REPAYMENT_CASH.
|
||||
pricePerGramRialAtEntry Decimal? @db.Decimal(20, 2)
|
||||
|
||||
orderId String?
|
||||
order Order? @relation(fields: [orderId], references: [id])
|
||||
|
||||
/// Pending entries don't count toward the cached customer balance until confirmed.
|
||||
confirmed Boolean @default(false)
|
||||
confirmedAt DateTime?
|
||||
postedByAdminUserId String?
|
||||
postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id])
|
||||
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([customerId, currency])
|
||||
@@index([orderId])
|
||||
}
|
||||
|
||||
/// Replaces legacy WebServiceGold / ConfigStatic.gold_price — timestamped price feed.
|
||||
model GoldPriceQuote {
|
||||
id String @id @default(cuid())
|
||||
karatPerMille Int
|
||||
pricePerGramRial Decimal @db.Decimal(20, 2)
|
||||
source String
|
||||
quotedAt DateTime @default(now())
|
||||
|
||||
@@index([karatPerMille, quotedAt])
|
||||
}
|
||||
|
||||
/// Time-limited gold cashback bonus — replaces the legacy Rooyesh\Wallet gold credit.
|
||||
model WalletBonus {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
orderId String?
|
||||
goldGrams Decimal @db.Decimal(14, 4)
|
||||
grantedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([customerId, isActive])
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Placeholder — replace with a real bcrypt hash once the auth module lands.
|
||||
const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production';
|
||||
|
||||
// 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';
|
||||
|
||||
async function main() {
|
||||
// ── 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) ────────────────────
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09120000001' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09120000001',
|
||||
fullName: 'مدیر ارشد سیستم',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SUPER_ADMIN,
|
||||
branchId: branchTehran.id,
|
||||
},
|
||||
});
|
||||
const agentReza = await prisma.adminUser.upsert({
|
||||
where: { mobile: '09121234567' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09121234567',
|
||||
fullName: 'رضا احمدی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
branchId: branchTehran.id,
|
||||
wagePercent: 1.5,
|
||||
},
|
||||
});
|
||||
const agentSara = await prisma.adminUser.upsert({
|
||||
where: { mobile: '09129876543' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09129876543',
|
||||
fullName: 'سارا کریمی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
branchId: branchMashhad.id,
|
||||
wagePercent: 1.2,
|
||||
},
|
||||
});
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09123334444' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09123334444',
|
||||
fullName: 'هومن یزدانی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||
},
|
||||
});
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09125556666' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09125556666',
|
||||
fullName: 'فرزانه محمودی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.ACCOUNTANT,
|
||||
branchId: branchTehran.id,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Customers ────────────────────────────────────────────────────────
|
||||
const customerData = [
|
||||
{
|
||||
id: 'customer-1',
|
||||
fullName: 'علی محمدی',
|
||||
mobile: '09151112233',
|
||||
nationalCode: '0012345678',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '200',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-2',
|
||||
fullName: 'مریم حسینی',
|
||||
mobile: '09151112234',
|
||||
nationalCode: '0012345679',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '100',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-3',
|
||||
fullName: 'جواد نوروزی',
|
||||
mobile: '09151112235',
|
||||
nationalCode: '0012345680',
|
||||
cityId: mashhad.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '50',
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
},
|
||||
{
|
||||
id: 'customer-4',
|
||||
fullName: 'نرگس ابراهیمی',
|
||||
mobile: '09151112236',
|
||||
nationalCode: '0012345681',
|
||||
cityId: isfahan.id,
|
||||
referredByAgentId: null,
|
||||
goldCreditLimitGrams: '30',
|
||||
verificationStep: VerificationStep.UNVERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-5',
|
||||
fullName: 'کامران صادقی',
|
||||
mobile: '09151112237',
|
||||
nationalCode: '0012345682',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '500',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-6',
|
||||
fullName: 'زهرا قاسمی',
|
||||
mobile: '09151112238',
|
||||
nationalCode: '0012345683',
|
||||
cityId: tabriz.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '80',
|
||||
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
|
||||
},
|
||||
];
|
||||
|
||||
const customers = [];
|
||||
for (const c of customerData) {
|
||||
customers.push(
|
||||
await prisma.customer.upsert({
|
||||
where: { id: c.id },
|
||||
update: {},
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const [customer1, customer2, , , customer5] = customers;
|
||||
|
||||
// ── 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 brandHenza = await prisma.brand.upsert({
|
||||
where: { id: 'brand-henza' },
|
||||
update: {},
|
||||
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: brandHenza.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: brandHenza.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: brandHenza.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 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,
|
||||
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' } });
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user