424 lines
15 KiB
Plaintext
424 lines
15 KiB
Plaintext
// 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])
|
|
}
|