Files
tehran_taj/apps/api/prisma/schema.prisma
T
s.esmaeiliandClaude Opus 5 69f4ba2b29 feat(api): sales-expert login to the customer apps (D21)
A sales expert can now sign into the apps with their AdminUser account, without
reversing D17 — the expert remains an AdminUser and is never turned back into a
Customer.

The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy
resolves both to one principal whose `id` is always a Customer id, so every
route already written for a customer works unchanged for an expert. That is what
the reference gave them, where an agent was a customer with extra screens.

Each expert gets ONE linked trading Customer row (additive migration, dry-run
clean and rolled back before applying) created at first sign-in, seeded from the
two numbers the reference read off the agent's own row: SalesExpertProfile
.gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent ->
makingChargeOffsetPercent. Orders the expert places land there, exactly as the
reference's single row did.

New expert-only routes scope on the AdminUser id from the token:
POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping
its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy
/my-child, :184) and GET expert/whoami so the apps can hide the entries.

Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's
app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back
to JWT_SECRET when unset — in that configuration the token would otherwise have
validated as an admin with full panel permissions.

NOT ported: myChild minted an api_token per listed customer (:197-201), letting
an agent act AS that customer. Recorded in D21 rather than silently dropped.

Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading
account seeding, own-customers-only (with both negatives), child orders, the
expert ordering for themselves, an ordinary customer refused both screens with
their own app unaffected, and all four token boundaries including immediate
lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43,
B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32.

[ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:05:36 +03:30

1099 lines
45 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"
// native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan)
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
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
}
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
///
/// SALES_AGENT is a LEGACY value only: legacy stored a sales agent as a
/// Customer row with type=1, but the redesigned system models a sales agent
/// as an AdminUser (role SALES_EXPERT) instead — see admin-users.service.ts
/// and admin-customers.service.ts's listReagents()/create(). No Customer row
/// should be created with kind SALES_AGENT going forward; the value is kept
/// only so legacyTypeToKind()/kindToLegacyType() stay total functions and any
/// already-migrated data keeps a readable label.
///
/// RETAIL marks a "تک" (single/retail) customer — legacy kept these in an
/// entirely separate table (SingleCustomer/`login`) that could never contain
/// a wholesale/agent/shopkeeper/banker row. The redesigned schema folded both
/// into one Customer model, so this value is what listSingle() now filters on
/// to reproduce that same separation.
enum CustomerKind {
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
BANKER // 4 بنکدار
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
RETAIL // مشتری تک — legacy SingleCustomer
}
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
}
/// Legacy kept retail and wholesale orders in two SEPARATE tables
/// (orders_orders vs orders). The redesigned schema has one Order model, so the
/// distinction lives here instead.
enum OrderChannel {
RETAIL // تک فروشی
WHOLESALE // عمده فروشی
}
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?
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
percent Decimal? @db.Decimal(6, 3)
percentAux Decimal? @db.Decimal(6, 3)
customers Customer[]
addresses CustomerAddress[]
branches Branch[]
preOrders PreOrder[]
salesExpertProfiles SalesExpertProfile[]
}
model AdminUser {
id String @id @default(cuid())
/// ⚠️ Kept in sync BY A DATABASE TRIGGER — `admin_user_sync_full_name`, from
/// migration 20260922180000. Prisma cannot see it, so a write here may store
/// something other than what you passed:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; the submitted fullName is
/// discarded
///
/// Re-read after writing if you need the stored value. Same arrangement as
/// Customer (D0 requirement 7 — the split applies to ALL admins, not only
/// sales experts).
fullName String
firstName String?
lastName String?
mobile String @unique
passwordHash String
role AdminRole
/// Legacy `users.ut`. Granular ACL lives here (Permission rows hang off UserType);
/// the coarse `role` enum above is retained for the business rules that already
/// switch on it. Nullable so existing seeded admins remain valid.
userTypeId Int?
userType UserType? @relation(fields: [userTypeId], references: [id])
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())
/// Legacy stored a sales agent as a `customers` row with type=1, so
/// ReagentCustomer.vue's shared form collected these alongside the
/// wholesale-customer fields. A sales agent is now an AdminUser
/// (role=SALES_EXPERT), so these live here instead — only ever set for that
/// role; every other AdminUser leaves them null.
avatarFileId String?
avatarFile UploadedFile? @relation("AdminUserAvatar", fields: [avatarFileId], references: [id])
address String?
storeName String? // legacy store_name
pasazhName String? // legacy pasazh_name
memberCode String? // legacy mcode
referredCustomers Customer[] @relation("ReferralAgent")
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
postedLedgerEntries LedgerEntry[]
orderStatusEvents OrderStatusEvent[]
preOrderStatusEvents PreOrderStatusEvent[]
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
sentNotifications Notification[] @relation("NotificationAuthor")
uploadedFiles UploadedFile[] @relation("FileUploader")
/// Only ever set for role = SALES_EXPERT (D17).
salesExpertProfile SalesExpertProfile?
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
@@index([firstName])
@@index([lastName])
}
/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
/// were lost when a sales expert became an AdminUser (D17).
///
/// A separate table rather than columns on AdminUser: these are meaningful only
/// for role = SALES_EXPERT and would be permanently null for every other admin.
/// A row is created for every SALES_EXPERT by the migration, and upserted by the
/// service whenever one is created or a user's role becomes SALES_EXPERT — but
/// readers still left-join, so a missing profile degrades to nulls rather than
/// dropping the agent from the grid.
///
/// `reagent_id` is deliberately NOT here — see D17: in the reference it was a
/// side effect of the shared customers form, and filling it removed that expert
/// from the selectable-agent list (queryReagent, AdminController.php:420).
model SalesExpertProfile {
id String @id @default(cuid())
adminUserId String @unique
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// Legacy customers.city_id — a filterable grid column
/// (ReagentListModel.js:14) and the source of the commission autofill
/// (ReagentCustomer.vue:215-228).
cityId String?
city City? @relation(fields: [cityId], references: [id])
/// Legacy customers.gram_level — the «سطح اعتبار» column, filterable.
/// Decimal(14,4) matches Customer.goldCreditLimitGrams exactly so the same
/// credit limit has identical precision on both tables.
gramLevel Decimal @default(0) @db.Decimal(14, 4)
/// Legacy customers.phone (varchar 15) and ncode (varchar 20). The reference
/// stores both and reads neither back; kept so the form matches it. Neither
/// is unique and neither has a validation rule in the reference — inventing
/// one would reject data the reference accepts.
phone String? @db.VarChar(15)
nationalCode String? @db.VarChar(20)
/// The Customer row carrying this expert's OWN orders, debt and credit limit.
///
/// The reference stored a sales agent AS a Customer (type=1), so an order the
/// agent placed from the app was simply their own, against their own
/// gram_level (ApiMobileController.php:534-535). Here the expert is an
/// AdminUser (D17), which has no ledger and cannot be an Order's subject, so
/// this one linked Customer plays that part.
///
/// Distinct from the expert's REFERRED customers, which are ordinary Customer
/// rows pointing at the AdminUser through Customer.referredByAgentId.
///
/// Null until the expert first signs into the app, when it is created from
/// this profile. See D21.
tradingCustomerId String? @unique
tradingCustomer Customer? @relation("SalesExpertTradingAccount", fields: [tradingCustomerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([cityId])
}
model Customer {
id String @id @default(cuid())
/// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE
/// TRIGGER — `customer_sync_full_name`, created in migration
/// 20260922120000_customer_name_parts. Prisma does not know about it, so a
/// write here is NOT the whole story:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten
/// with "first last" and whatever you passed for fullName is discarded
///
/// So a value you wrote can differ from the value that lands in the row.
/// Re-read after writing if you need the stored value. Prefer writing the
/// parts; `fullName` exists for the legacy readers that predate D0.
///
/// Reference has these as two columns from the start
/// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname);
/// the port collapsed them, which is the D0 defect.
fullName String
firstName String?
lastName String?
mobile String @unique
nationalCode String @unique
passwordHash String
cityId String?
city City? @relation(fields: [cityId], references: [id])
/// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the
/// BankerList / ShopkeeperList screens filter on.
kind CustomerKind @default(APP_SHOPKEEPER)
/// Legacy contact + shop columns, shown on the wholesale customer form and as
/// (hidden-by-default) columns in its grid.
phone String?
address String?
storeName String?
pasazhName String?
zone String?
/// Legacy mcode — the operator-assigned customer code.
memberCode String?
referredByAgentId String?
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
/// Set only on the one Customer row that acts as a sales expert's own trading
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
/// ordinary customer.
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
// 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
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
/// ADDED to the variant's making charge when pricing an order line
/// (henza_pwa src/Mixins/product/index.ts:69-75:
/// getWage(v) = state.percent + v.percentage_pay)
/// and it shifts the catalogue making-charge filter bounds
/// (ApiMobileController.php:391-396,76-77). It is NOT part of the price
/// formula itself — that extra percent is ConfigStatic['product_price_extra_percent'].
///
/// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and
/// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot).
///
/// DB default 0 keeps every existing customer's price unchanged. The
/// reference's application-layer default for NEW customers is 3
/// (AdminController.php:338) and the field is required on create (:354).
makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3)
verificationStep VerificationStep @default(UNVERIFIED)
nationalIdImageUrl String?
businessLicenseUrl String?
shopImageUrl String?
/// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent
/// form. Legacy stored an FK to its Image table and read it back as a nested
/// `image: { id, image }`, so this is a relation, not a bare URL string.
avatarFileId String?
avatarFile UploadedFile? @relation("CustomerAvatar", fields: [avatarFileId], references: [id])
/// Legacy had TWO independent switches on a customer:
/// sts -> may they place orders (this field)
/// active -> is the account enabled (isActive, below)
/// The port previously drove both from isActive; they are separate again.
canPlaceOrders Boolean @default(true)
isActive Boolean @default(true)
/// Legacy archive flag — the archived list filtered on this, not on isActive.
isArchived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
addresses CustomerAddress[]
orders Order[]
preOrders PreOrder[]
ledgerEntries LedgerEntry[]
walletBonuses WalletBonus[]
comments Comment[]
promotions Promotion[]
notifications NotificationRecipient[]
activityLogs CustomerActivityLog[]
otpChallenges CustomerOtp[]
@@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
}
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
/// Legacy Branch.address / phone — both are columns in the branch grid.
address String?
phone String?
/// Legacy GET branch/active/{id} toggles this.
isActive Boolean @default(true)
cityId String?
city City? @relation(fields: [cityId], references: [id])
adminUsers AdminUser[]
orders Order[]
allocations BranchAllocation[]
}
model Workshop {
id String @id @default(cuid())
name String
/// Legacy Workshop.address / phone — both are columns in the workshop grid.
address String?
phone String?
/// The supervisor created alongside the workshop by the legacy Add form.
supervisorId String?
supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
isActive Boolean @default(true)
preOrders PreOrder[]
}
// ─────────────────────────────────────────────────────────────────────────
// Catalog — item reference catalog
// ─────────────────────────────────────────────────────────────────────────
model Category {
id String @id @default(cuid())
title String
/// Legacy Category.type — which storefront the category belongs to:
/// 1 سایت | 2 اپلیکشن موبایل. Drives the "type_str" grid column.
type Int @default(1)
/// Legacy Category.image / icon — the grid renders the image, the storefront the icon.
imageUrl String?
iconUrl String?
/// Legacy meta tag + description, used for SEO on the site categories.
metaTag String?
description String?
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
products Product[]
promotions Promotion[]
}
model Brand {
id String @id @default(cuid())
title String
products Product[]
promotions Promotion[]
}
/// 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])
/// Legacy Product.product_code — the human-facing SKU shown in the grid.
productCode String? @unique
/// Legacy Product.show_type: 1 site, 2 mobile, 3 both.
showType Int @default(3)
isActive Boolean @default(true)
createdAt DateTime @default(now())
variants ProductVariant[]
images ProductImage[]
collections CollectionProduct[]
comments Comment[]
promotions Promotion[]
allocationItems BranchAllocationItem[]
}
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
url String
sortOrder Int @default(0)
/// Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
/// existing rows and any generic "just attach an image" caller keep working.
role ProductImageRole @default(GALLERY)
/// Variants that display this image. A gallery image may back several
/// variants (one photo, several weights), which is why this is a plain
/// one-to-many and not a join table.
variants ProductVariant[]
}
enum ProductImageRole {
FEATURED
FEATURED_MOBILE
GALLERY
GALLERY_MOBILE
}
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)
/// Legacy ProductVariant.qty — on-hand stock. The cart refuses a variant with
/// qty 0 unless it is flagged pre-order, so both fields are required for that
/// guard to work (see apps/admin-panel/src/stores/cart.ts).
qty Int @default(0)
/// Legacy is_pre_order: orderable even at zero stock.
isPreOrder Boolean @default(false)
isActive Boolean @default(true)
/// The gallery image shown for this variant, picked from its own product's
/// images — never a re-uploaded copy. onDelete: SetNull because dropping an
/// image must never cascade into the variant: OrderItem rows reference
/// variants, so losing one would break order history.
imageId String?
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
orderItems OrderItem[]
@@index([imageId])
}
// ─────────────────────────────────────────────────────────────────────────
// 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])
/// Which sales channel placed this order — see OrderChannel.
channel OrderChannel @default(RETAIL)
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])
/// Instrument metadata from the legacy "cash" document form. Which fields are
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
/// the pre-normalization weight, a cheque carries the account and bank, and a
/// transfer carries its destination.
/// Legacy columns: lab, ang, account_name, bank_name, to, original_value, recive_date.
labName String?
hallmark String?
accountName String?
bankName String?
transferTo String?
/// Weight as handed over, BEFORE karat normalization (legacy original_value).
originalValue Decimal? @db.Decimal(14, 4)
/// Rial amount for cash/cheque documents (legacy price).
priceRial Decimal? @db.Decimal(20, 2)
receivedAt DateTime?
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])
}
// ─────────────────────────────────────────────────────────────────────────
// Admin ACL — faithful port of the legacy granular permission model.
//
// Legacy (henza_server):
// Schema::create('permission', function (Blueprint $table) {
// $table->string('rid');
// $table->integer('ut');
// $table->primary(['rid' , 'ut']);
// });
//
// Permissions attach to the USER TYPE (`ut`), never to an individual user, so
// every admin of a given type shares one permission set. `ut` pointed at a row
// in the legacy `config_static` lookup table; UserType is that table, narrowed
// to the admin-user-type rows it was actually used for.
//
// A `rid` is a dotted permission id such as "persons.list.client.omde". The
// frontend expands each stored rid into its ancestors at runtime via
// checkParent() — see apps/admin-panel/src/stores/users.ts. Only the 58 leaf
// rids the routers actually gate are seeded; ancestors are derived, not stored.
// ─────────────────────────────────────────────────────────────────────────
model UserType {
/// Legacy `permission.ut` / `users.ut` — an integer, kept as the PK so seeded
/// rows keep their legacy identity rather than getting new cuids.
id Int @id
title String
isActive Boolean @default(true)
createdAt DateTime @default(now())
permissions Permission[]
adminUsers AdminUser[]
@@map("user_type")
}
model Permission {
/// Dotted permission id, e.g. "order.list", "persons.list.client.omde".
rid String
userTypeId Int
userType UserType @relation(fields: [userTypeId], references: [id], onDelete: Cascade)
/// Legacy's composite primary key (rid, ut), preserved exactly.
@@id([rid, userTypeId])
@@index([userTypeId])
@@map("permission")
}
// ─────────────────────────────────────────────────────────────────────────
// Product comments / reviews — legacy `comment` table (App\Models\Comment),
// moderated from views/customer/Comments/Comments.vue.
//
// Legacy fillable: product_id, comment, reply_to, sts, customer_id
// Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده']
// which is represented here as an enum rather than a magic integer.
// ─────────────────────────────────────────────────────────────────────────
enum CommentStatus {
PENDING // 1 — در انتظار تایید
APPROVED // 2 — تایید شده
REJECTED // 3 — رد شده
}
model Comment {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
customerId String
customer Customer @relation(fields: [customerId], references: [id])
body String
status CommentStatus @default(PENDING)
/// Legacy `reply_to`: a staff reply threads under the comment it answers.
replyToId String?
replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id])
replies Comment[] @relation("CommentReplies")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId])
@@index([customerId])
@@index([status])
}
// ─────────────────────────────────────────────────────────────────────────
// Settings — replaces legacy `config_static`, a key/value table the admin
// panel read and wrote through endpoints like /wallet/config and
// /const/__list__ut. Values are stored as JSON so a setting can hold a scalar,
// an object, or the dynamic option lists legacy kept under `__list__*` keys.
// ─────────────────────────────────────────────────────────────────────────
model Setting {
key String @id
value Json
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Promotions / discounts — legacy views/promotion/*, served by
// PromotionController (`product/variant/promotion`). The redesigned schema had
// no promotion concept at all; this models what the legacy forms actually
// collect.
// ─────────────────────────────────────────────────────────────────────────
/// Legacy Promotion.type — what the discount is scoped to.
enum PromotionScope {
PRODUCT // 1 محصولات
CATEGORY // 2 دسته بندی
CUSTOMER // 3 مشتری
BRAND // 4 برند
}
/// Legacy Promotion.use_only — how often a customer may redeem it.
enum PromotionUsage {
ONCE // 1 فقط یکبار
UNTIL_END_DATE // 2 تا تاریخ پایان
}
model Promotion {
id String @id @default(cuid())
title String
description String?
scope PromotionScope @default(PRODUCT)
usage PromotionUsage @default(ONCE)
/// Percentage off, 0-100 (legacy discount_percent).
discountPercent Decimal @db.Decimal(6, 3)
isActive Boolean @default(true)
/// Which storefront it shows on, mirroring Product.showType (legacy platform).
platform Int @default(3)
startsAt DateTime?
endsAt DateTime?
/// Exactly one of these is populated, per `scope`.
productId String?
product Product? @relation(fields: [productId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
brandId String?
brand Brand? @relation(fields: [brandId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scope, isActive])
}
/// Legacy "ignore" table (views/offersIgnore) — specific products, categories,
/// customers or brands excluded from the automatic discount engine.
/// Legacy posts { type, item_id[] }: "type" reuses the promotion scope codes
/// (1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند) and "itemId" is the excluded row.
model PromotionExclusion {
id String @id @default(cuid())
type Int
itemId String
createdAt DateTime @default(now())
@@unique([type, itemId])
@@index([type])
}
// ─────────────────────────────────────────────────────────────────────────
// Branch stock allocation — legacy views/invoice/* ("فاکتور انتقال"), served by
// BranchController as `branch/product`. Despite the screen being called an
// invoice, it is not a sales document: it records which products head office
// allocated to a branch, and who allocated them.
// ─────────────────────────────────────────────────────────────────────────
model BranchAllocation {
id String @id @default(cuid())
branchId String
branch Branch @relation(fields: [branchId], references: [id])
/// Legacy `allocator_user` — the admin who filed the allocation.
allocatorAdminUserId String?
allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id])
note String?
createdAt DateTime @default(now())
items BranchAllocationItem[]
@@index([branchId])
}
model BranchAllocationItem {
id String @id @default(cuid())
allocationId String
allocation BranchAllocation @relation(fields: [allocationId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
/// Weight snapshot at allocation time, so later catalog edits do not rewrite history.
weightGrams Decimal? @db.Decimal(14, 4)
@@unique([allocationId, productId])
}
// ─────────────────────────────────────────────────────────────────────────
// Messaging + audit — legacy views/sms, views/notify, views/newsletters and
// views/statics. None of these had a model in the redesigned schema.
// ─────────────────────────────────────────────────────────────────────────
/// Who an SMS or notification was aimed at. Legacy's sms/create offered these
/// audiences, each gated by its own rid (sms.create.all, .omde, .single, ...).
enum MessageAudience {
SINGLE_NUMBER // یک شماره
MULTIPLE_NUMBERS // چند شماره
ALL_CUSTOMERS // همه
WHOLESALE_CUSTOMERS // عمده
RETAIL_CUSTOMERS // تک
SALES_AGENTS // کارشناسان فروش
}
model SmsMessage {
id String @id @default(cuid())
audience MessageAudience
/// Comma-separated recipients for the SINGLE/MULTIPLE audiences.
recipients String?
body String
/// How many numbers the send actually resolved to.
sentCount Int @default(0)
sentByAdminUserId String?
sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id])
createdAt DateTime @default(now())
@@index([createdAt])
}
model Notification {
id String @id @default(cuid())
title String
body String
audience MessageAudience @default(ALL_CUSTOMERS)
sentByAdminUserId String?
sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id])
createdAt DateTime @default(now())
recipients NotificationRecipient[]
@@index([createdAt])
}
/// Per-customer delivery + read state — legacy `GET notify/read/{id}`.
model NotificationRecipient {
id String @id @default(cuid())
notificationId String
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
customerId String
customer Customer @relation(fields: [customerId], references: [id])
readAt DateTime?
@@unique([notificationId, customerId])
@@index([customerId, readAt])
}
/// Legacy views/newsletters — the mailing-list signups collected by the site.
model NewsletterSubscriber {
id String @id @default(cuid())
email String?
mobile String?
createdAt DateTime @default(now())
@@unique([email])
@@unique([mobile])
}
/// Legacy views/statics — the customer activity log the "آمار" screen reads
/// (`log/list/customer`, `log/ago/{customerId}`).
model CustomerActivityLog {
id String @id @default(cuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
/// Free-form action key, e.g. 'login', 'order.placed', 'profile.updated'.
action String
detail String?
createdAt DateTime @default(now())
@@index([customerId, createdAt])
}
// ─────────────────────────────────────────────────────────────────────────
// Uploads — replaces the legacy MyUpload / SingleUploadInput widgets, which
// POSTed to a file endpoint and stored the returned URL on the owning record.
// Ports of those forms currently take a URL by hand; this gives them a real
// destination to upload to.
// ─────────────────────────────────────────────────────────────────────────
model UploadedFile {
id String @id @default(cuid())
/// Public URL the stored file is served from.
url String
originalName String
mimeType String
sizeBytes Int
/// Free-form tag for what the file belongs to, e.g. 'product', 'category',
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
purpose String?
uploadedByAdminUserId String?
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
createdAt DateTime @default(now())
customerAvatars Customer[] @relation("CustomerAvatar")
adminUserAvatars AdminUser[] @relation("AdminUserAvatar")
@@index([purpose])
}
/// One OTP login challenge for a customer.
///
/// Ports henza_server's flow (ApiMobileController.php:39-78), where userAuth
/// generated rand(1000,9999), stored it on the customer row as `send_password`
/// and sent it by SMS, and userVerfiy compared the submitted code against it
/// (mismatch -> HTTP 422).
///
/// Three deliberate improvements over the reference, approved by the user
/// (docs/parity/DECISIONS.md D6 and T1):
/// 1. the code is stored as a bcrypt hash, never in plain text — the
/// reference kept it readable in a customer column;
/// 2. it expires (`expiresAt`) — the reference's code stayed valid forever;
/// 3. attempts are counted and the challenge locks out — the reference had
/// no limit at all.
/// The parity behaviour itself is the code comparison and the rejection.
model CustomerOtp {
id String @id @default(cuid())
/// Normalised to English digits before storing, as the reference does with
/// Utility::faTOen() on both send and verify.
mobile String
customerId String
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// bcrypt hash of the 4-digit code. Never the code itself.
codeHash String
expiresAt DateTime
/// Failed verify attempts against THIS challenge.
attempts Int @default(0)
/// Set when the code is accepted, so one challenge cannot be reused.
consumedAt DateTime?
/// Kept for the per-IP send throttle.
requestIp String?
createdAt DateTime @default(now())
@@index([mobile, createdAt])
@@index([customerId])
@@index([expiresAt])
}
/// Bespoke migration ledger left by the EARLIER, pre-Prisma port.
///
/// It predates Prisma Migrate in this repo and holds 12 historical rows
/// (`0001_init.sql`, `0002_admin_schema.sql`, `seed:initial`, and the early
/// `2026091…` migrations). It is the only record of how that port was applied,
/// so it is **kept and never written to or deleted from** (DEAD-07).
///
/// It is declared here purely so `prisma migrate diff` stops proposing
/// `DROP TABLE "_goldbank_migrations"` — an undeclared table reads as drift.
/// `@@ignore` keeps it out of Prisma Client: nothing in the application should
/// ever read or write it, and Prisma Migrate will not manage its shape either.
///
/// Prisma's own ledger is `_prisma_migrations`, which is separate and is never
/// edited by hand (D14).
model goldbank_migrations {
name String @id
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(6)
@@map("_goldbank_migrations")
@@ignore
}