- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync - Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency) - Settlement document and expert inputs validated (400 instead of 500) - Expert: edit / activate-deactivate own customers, adjust raw weights only - Made-to-order flow in the app, PreOrder.quantity migration - Order grids: customer column, sort keys; panel select search; pre-order quantity save - Rebuilt release artifacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1500 lines
62 KiB
Plaintext
1500 lines
62 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 had THREE states per step, not two
|
|
/// (MajorCustomerController.php:106-135): 1 = awaiting, 2 = approved,
|
|
/// 3 = REJECTED. Mapping rejected customers to UNVERIFIED would put closed
|
|
/// cases back in the review queue, so the owner ruled it out (2026-10-07).
|
|
/// 14 legacy rows land here; see Customer.verificationRejectionReason.
|
|
REJECTED
|
|
}
|
|
|
|
/// 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
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Legacy `cities` is a tree (country > province > city > district) and so is
|
|
/// this: a city hangs under its `parent`. In the admin pickers a top-level row
|
|
/// (the country) is a heading, not selectable (henza_admin citiesModel.js:38).
|
|
model City {
|
|
id String @id @default(cuid())
|
|
name String
|
|
parentId String?
|
|
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Restrict)
|
|
children City[] @relation("CityTree")
|
|
/// 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[]
|
|
|
|
@@index([parentId])
|
|
}
|
|
|
|
/// A staff account for the ADMIN PANEL — and nothing else.
|
|
///
|
|
/// Split out of AdminUser (which now holds sales experts only) so that:
|
|
/// * the same mobile can be an admin AND a sales expert — they are two
|
|
/// separate roles, each unique only within its own table;
|
|
/// * the expert table (production: ~12,000 rows) is never written by panel
|
|
/// staff management.
|
|
/// Migration 20261026120000_split_admin_table copies the existing non-expert
|
|
/// AdminUser rows here WITH THEIR IDS, so issued panel tokens and every
|
|
/// "who did this" column keep resolving. The old rows are left in AdminUser,
|
|
/// dormant; no code reads them as admins any more.
|
|
///
|
|
/// ⚠️ fullName/firstName/lastName are kept in sync by the DB trigger
|
|
/// `admin_sync_full_name_trg` (it shares the function admin_user_sync_full_name) — same semantics as AdminUser: the parts win.
|
|
model Admin {
|
|
id String @id @default(cuid())
|
|
|
|
fullName String
|
|
firstName String?
|
|
lastName String?
|
|
|
|
mobile String @unique
|
|
passwordHash String
|
|
/// Never SALES_EXPERT — that role lives in AdminUser. Enforced in
|
|
/// AdminUsersService, not in the database, so the enum stays shared.
|
|
role AdminRole
|
|
userTypeId Int?
|
|
userType UserType? @relation(fields: [userTypeId], references: [id])
|
|
branchId String?
|
|
branch Branch? @relation(fields: [branchId], references: [id])
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
|
|
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")
|
|
|
|
/// Issued admin-panel tokens, recorded only while session tracking is on
|
|
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
|
/// token can be revoked before it expires. See AdminSession.
|
|
sessions AdminSession[]
|
|
|
|
@@index([role])
|
|
@@index([userTypeId])
|
|
@@index([firstName])
|
|
@@index([lastName])
|
|
}
|
|
|
|
/// A SALES EXPERT (role SALES_EXPERT). Panel staff live in `Admin`; rows here with
|
|
/// any other role are pre-split leftovers and are ignored by the code.
|
|
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")
|
|
/// Orders credited to this sales expert — see Order.salesAgentId.
|
|
creditedOrders Order[] @relation("OrderSalesAgent")
|
|
|
|
/// Only ever set for role = SALES_EXPERT (D17).
|
|
salesExpertProfile SalesExpertProfile?
|
|
|
|
@@index([role])
|
|
@@index([userTypeId])
|
|
@@index([avatarFileId])
|
|
@@index([firstName])
|
|
@@index([lastName])
|
|
}
|
|
|
|
/// An issued APP token (customer app / sales-expert app), so it can be revoked
|
|
/// before it expires — the counterpart of AdminSession for the other token family.
|
|
///
|
|
/// WHY A SEPARATE TABLE AND NO FOREIGN KEY
|
|
/// ---------------------------------------
|
|
/// An app token's `sub` is a Customer id for a customer and an **AdminUser id**
|
|
/// for a sales expert (D21). One FK cannot point at two tables, so the subject is
|
|
/// stored as a plain id plus its type. The rows are short-lived operational data,
|
|
/// not part of the accounting model, so the missing FK costs nothing here.
|
|
model AppSession {
|
|
id String @id @default(cuid())
|
|
|
|
/// The token's `jti` claim. Unique: one row per issued token.
|
|
jti String @unique
|
|
|
|
/// Customer.id, or AdminUser.id when subjectType is "sales-expert".
|
|
subjectId String
|
|
subjectType String
|
|
|
|
mobile String?
|
|
|
|
/// Set when the login was confirmed by the central IAM: its session id (`sid`)
|
|
/// and stable user id (`sub`), which is what a back-channel logout arrives with.
|
|
iamSessionId String?
|
|
iamSub String?
|
|
|
|
ipAddress String?
|
|
userAgent String?
|
|
|
|
createdAt DateTime @default(now())
|
|
expiresAt DateTime?
|
|
lastSeenAt DateTime?
|
|
|
|
revokedAt DateTime?
|
|
revokedReason String?
|
|
|
|
@@index([subjectId])
|
|
@@index([iamSessionId])
|
|
@@index([iamSub])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// An issued admin-panel JWT, so it can be revoked before it expires.
|
|
///
|
|
/// WHY THIS EXISTS
|
|
/// ---------------
|
|
/// The admin token is self-contained and valid for 12h, which means nothing —
|
|
/// not disabling the account, not a central "sign out everywhere" — could cut a
|
|
/// session short: the token kept working until it expired. With the central IAM
|
|
/// in the loop that gap became visible (an IAM session revocation had no effect
|
|
/// here), so every issued token now carries a `jti` recorded on this table and
|
|
/// JwtStrategy refuses a token whose row is revoked.
|
|
///
|
|
/// Rows are written ONLY while tracking is enabled, and JwtStrategy only
|
|
/// enforces them while it is enabled, so a deployment that does not want the
|
|
/// extra lookup behaves exactly as before.
|
|
model AdminSession {
|
|
id String @id @default(cuid())
|
|
|
|
/// The token's `jti` claim. Unique: one row per issued token.
|
|
jti String @unique
|
|
|
|
adminId String @map("adminUserId")
|
|
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
|
|
|
/// Set when the login was verified by the IAM: its session id (`sid`) and the
|
|
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
|
|
/// deliberately here rather than as columns on AdminUser, so enabling the
|
|
/// feature needs no change to an existing table.
|
|
iamSessionId String?
|
|
iamSub String?
|
|
|
|
ipAddress String?
|
|
userAgent String?
|
|
|
|
createdAt DateTime @default(now())
|
|
expiresAt DateTime?
|
|
lastSeenAt DateTime?
|
|
|
|
revokedAt DateTime?
|
|
revokedReason String?
|
|
|
|
@@index([adminId], map: "AdminSession_adminUserId_idx")
|
|
@@index([iamSessionId])
|
|
@@index([iamSub])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// 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
|
|
/// Legacy customers.ncode. OPTIONAL since 2026-10-07: not one of the 1,616
|
|
/// legacy customers has a national code (1595/1595 empty), and fabricating
|
|
/// one is a data violation that would block later legal identity checks
|
|
/// (owner ruling). Still @unique — in PostgreSQL NULLs do not collide, so
|
|
/// the migration tool must write NULL and never '', which would collide on
|
|
/// the second row.
|
|
///
|
|
/// Three service-layer workarounds exist ONLY because this used to be
|
|
/// required, and they store the MOBILE NUMBER here:
|
|
/// admin-customers.service.ts:625,685 and customer-auth.service.ts:258.
|
|
/// Retiring them is a separate change; see docs/DATA-MIGRATION-MAP.md §10.4.
|
|
nationalCode String? @unique
|
|
passwordHash String
|
|
cityId String?
|
|
city City? @relation(fields: [cityId], references: [id])
|
|
|
|
/// Legacy customers.new_city — the city name exactly as the customer typed it
|
|
/// at app sign-up (Major/CustomerController.php:36). Kept verbatim because
|
|
/// the legacy city table has only 15 rows, so the typed name is often the only
|
|
/// record of where the customer is (172 customers; 43 match no known city).
|
|
/// cityId is set alongside it only on an exact, ي/ك-normalised name match.
|
|
/// Legacy sign-up also forced city_id = 5 (Tehran) on everyone
|
|
/// (Major/CustomerController.php:21), so for those rows the typed name, not
|
|
/// cityId, is the truth. Owner ruling 2026-10-07: keep both.
|
|
cityNameRaw String?
|
|
|
|
/// 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")
|
|
/// Legacy orders that named THIS customer as their referrer — see
|
|
/// Order.referrerCustomerId. History only.
|
|
referredOrders Order[] @relation("OrderReferrerCustomer")
|
|
|
|
// 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
|
|
|
|
/// Balance as last read from the accounting software (D27), in the app's own
|
|
/// sign convention (positive = owes). Shown to the customer when the
|
|
/// integration is on; the credit-limit check still uses goldDebtGrams above.
|
|
/// Null until the first successful sync.
|
|
accountingGoldBalance Decimal? @db.Decimal(14, 4)
|
|
accountingRialBalance Decimal? @db.Decimal(20, 2)
|
|
accountingBalanceAt DateTime?
|
|
|
|
/// 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?
|
|
|
|
/// Why verification was rejected (VerificationStep.REJECTED).
|
|
///
|
|
/// Legacy recorded NO reason: none of its 49 `customers` columns is a
|
|
/// reason/note field, and on rejection it only sent a fixed SMS. So the 14
|
|
/// rows migrated from the legacy system carry the sentinel
|
|
/// «رد شده در سیستم قدیم — فاقد شرح علت ثبتشده» instead of NULL, so support
|
|
/// knows why it is blank (owner ruling 2026-10-07). The migration tool
|
|
/// writes it only WHERE this IS NULL, so a re-run cannot overwrite a real
|
|
/// reason entered in the new system.
|
|
verificationRejectionReason String?
|
|
|
|
/// NULL for rows migrated from the legacy system — it recorded no rejection time.
|
|
verificationRejectedAt DateTime?
|
|
|
|
/// Legacy customers.birth_date, populated on 917 of 1,595 rows. Kept for the
|
|
/// birthday SMS / loyalty campaigns (owner ruling 2026-10-07).
|
|
///
|
|
/// ⚠ Legacy stores dates as JALALI STRINGS in other tables
|
|
/// (cashes.recive_date is varchar, 88/89 values starting "14…"), so this
|
|
/// timestamp may hold mis-entered Jalali years. Confirm the calendar with
|
|
/// docs/migration-precheck-3.sql BEFORE backfilling: copying them unchanged
|
|
/// would produce 917 wrong birthdays, worse for an SMS campaign than none.
|
|
birthDate DateTime?
|
|
|
|
/// 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[]
|
|
favorites CustomerFavorite[]
|
|
|
|
@@index([referredByAgentId])
|
|
@@index([firstName])
|
|
@@index([lastName])
|
|
}
|
|
|
|
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
|
|
/// so every customer's favourites would have been lost in the migration; the
|
|
/// owner confirmed on 2026-10-06 that they matter.
|
|
///
|
|
/// Legacy `fav` has two columns and NO primary key. The composite key here
|
|
/// supplies the constraint the source lacks (the precheck found 0 duplicate
|
|
/// pairs, but the tool should still SELECT DISTINCT). 14 of the 251 rows point
|
|
/// at a deleted customer and must be skipped.
|
|
///
|
|
/// Legacy's denormalised `product.count_fav` counter is deliberately NOT
|
|
/// recreated as a column — derive it from this table.
|
|
model CustomerFavorite {
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@id([customerId, productId])
|
|
@@index([productId])
|
|
}
|
|
|
|
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[]
|
|
admins Admin[]
|
|
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 Admin? @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())
|
|
|
|
/// Stock of the linked کارساخته as last read from the accounting software
|
|
/// (D27). Weight for gram-type items, count for count-type ones — whichever
|
|
/// the software reports. At zero or below the product is unavailable in the
|
|
/// app (pre-order variants excepted). Null = not linked or never synced.
|
|
accountingStockWeight Decimal? @db.Decimal(14, 4)
|
|
accountingStockCount Decimal? @db.Decimal(14, 4)
|
|
accountingStockAt DateTime?
|
|
|
|
variants ProductVariant[]
|
|
images ProductImage[]
|
|
collections CollectionProduct[]
|
|
comments Comment[]
|
|
promotions Promotion[]
|
|
allocationItems BranchAllocationItem[]
|
|
favoritedBy CustomerFavorite[]
|
|
|
|
// Storefront paging (owner: the real catalogue reaches ~16k products, so
|
|
// GET /catalog/products must page rather than return everything). This model
|
|
// had NO index at all, so every storefront query was a full scan and paging
|
|
// alone would not have been any faster.
|
|
//
|
|
// The composite matches the list's own shape: filter on isActive, order by
|
|
// createdAt — Postgres can satisfy both from this one index, including the
|
|
// OFFSET walk. The other two serve the category and brand filters.
|
|
@@index([isActive, createdAt])
|
|
@@index([categoryId])
|
|
@@index([brandId])
|
|
}
|
|
|
|
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[]
|
|
|
|
// The storefront list filters on `images: { some: {} }` and includes the
|
|
// gallery ordered by sortOrder, both per product. Unindexed, each one scanned
|
|
// the whole table.
|
|
@@index([productId, sortOrder])
|
|
}
|
|
|
|
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)
|
|
|
|
/// Legacy color enum: 0=سفید, 1=زرد, 2=شکلاتی, 3=رزگلد
|
|
color Int?
|
|
/// Legacy rial_pay — fixed Rial making charge per variant.
|
|
rialWage Decimal? @db.Decimal(18, 0)
|
|
/// Legacy details — short description for variant.
|
|
details String? @db.Text
|
|
/// Legacy description — full description for variant.
|
|
description String? @db.Text
|
|
|
|
/// 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])
|
|
// Every storefront product carries its variants, so this include ran once per
|
|
// product with no index to use. The composite covers the `isActive: true`
|
|
// filter the list applies to them.
|
|
@@index([productId, isActive])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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
|
|
|
|
/// The sales expert this order is credited to, fixed when it is placed:
|
|
/// the customer's referring expert AT THAT MOMENT. Legacy `orders.reagent_id`,
|
|
/// set once at creation (ApiMobileController.php:601) and never moved, so a
|
|
/// customer changing expert later does not move past sales or commission
|
|
/// (owner ruling 2026-10-08). «میزان فروش» counts from here, never from the
|
|
/// customer's current referrer.
|
|
salesAgentId String?
|
|
salesAgent AdminUser? @relation("OrderSalesAgent", fields: [salesAgentId], references: [id], onDelete: SetNull)
|
|
|
|
/// History only: a legacy order whose `reagent_id` named a customer who is
|
|
/// not a sales expert (one such customer, 17 orders in 2022). Legacy counted
|
|
/// those orders in that customer's own «میزان فروش»; this keeps that figure
|
|
/// exact. Never set for a new order.
|
|
referrerCustomerId String?
|
|
referrerCustomer Customer? @relation("OrderReferrerCustomer", fields: [referrerCustomerId], references: [id], onDelete: SetNull)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
items OrderItem[]
|
|
statusEvents OrderStatusEvent[]
|
|
ledgerEntries LedgerEntry[]
|
|
convertedFromPreOrder PreOrder?
|
|
|
|
@@index([customerId])
|
|
@@index([status])
|
|
@@index([salesAgentId])
|
|
@@index([referrerCustomerId])
|
|
}
|
|
|
|
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?
|
|
actorAdminId String? @map("actorAdminUserId")
|
|
actorAdmin Admin? @relation(fields: [actorAdminId], 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?
|
|
/// Legacy custom_orders.sum — how many pieces the customer asked for. The first
|
|
/// schema had no field for it and the admin grid showed a hard-coded 1.
|
|
quantity Int @default(1)
|
|
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
|
|
actorAdminId String? @map("actorAdminUserId")
|
|
actorAdmin Admin? @relation(fields: [actorAdminId], 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?
|
|
postedByAdminId String? @map("postedByAdminUserId")
|
|
postedByAdmin Admin? @relation(fields: [postedByAdminId], 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[]
|
|
admins Admin[]
|
|
|
|
@@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
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Accounting integration (D27) — the shop's accounting software, Tahesab or
|
|
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
|
|
// `accounting.config` (never served by the generic `const` routes). Plan:
|
|
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
|
//
|
|
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
|
|
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
|
|
// switching provider and back loses nothing.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
enum AccountingEntityType {
|
|
CUSTOMER
|
|
PRODUCT
|
|
}
|
|
|
|
/// One app row ↔ one record in the accounting software (customer account or
|
|
/// کارساخته).
|
|
model AccountingLink {
|
|
id String @id @default(cuid())
|
|
provider String
|
|
entityType AccountingEntityType
|
|
entityId String
|
|
/// The record's code in the accounting software (Tahesab Moshtari_Code /
|
|
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
|
|
externalCode String
|
|
externalName String?
|
|
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
|
|
/// row created from the accounting record), PUSHED (accounting record created
|
|
/// from the app row) or MANUAL (an admin typed the code).
|
|
origin String
|
|
/// The record as last read, for display only.
|
|
snapshot Json?
|
|
syncedAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([provider, entityType, entityId])
|
|
@@unique([provider, entityType, externalCode])
|
|
@@index([entityType, entityId])
|
|
}
|
|
|
|
enum AccountingOutboxKind {
|
|
CUSTOMER_PUSH
|
|
PRODUCT_PUSH
|
|
ORDER_DOCUMENT
|
|
ORDER_DOCUMENT_DELETE
|
|
}
|
|
|
|
enum AccountingOutboxStatus {
|
|
PENDING
|
|
/// A call was in flight. Found at startup, it means the process died mid-call
|
|
/// and the outcome is unknown — see AccountingOutboxWorker.
|
|
SENDING
|
|
SENT
|
|
FAILED
|
|
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
|
|
/// idempotency key, so a blind retry could post the document twice).
|
|
NEEDS_REVIEW
|
|
/// Closed by an admin from the log screen.
|
|
RESOLVED
|
|
}
|
|
|
|
/// Work to push to the accounting software. Written in the SAME transaction as
|
|
/// the change that causes it, so an outage of the accounting software never
|
|
/// loses an event and never blocks the app.
|
|
model AccountingOutbox {
|
|
id String @id @default(cuid())
|
|
provider String
|
|
kind AccountingOutboxKind
|
|
entityId String
|
|
status AccountingOutboxStatus @default(PENDING)
|
|
attempts Int @default(0)
|
|
nextAttemptAt DateTime @default(now())
|
|
lastError String?
|
|
/// What the accounting software returned as the document/record id.
|
|
externalRef String?
|
|
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
|
|
/// line), so a failure resumes where it stopped.
|
|
progress Json?
|
|
/// Last request/response, for the log screen. Never contains the token.
|
|
lastRequest Json?
|
|
lastResponse Json?
|
|
sentAt DateTime?
|
|
resolvedById String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([status, nextAttemptAt])
|
|
@@index([kind, entityId])
|
|
}
|
|
|
|
/// Something a pull found that needs a human: an account with no mobile, two
|
|
/// accounts sharing one mobile, a کارساخته group with no category mapping.
|
|
model AccountingIssue {
|
|
id String @id @default(cuid())
|
|
provider String
|
|
kind String
|
|
externalCode String
|
|
entityId String?
|
|
message String
|
|
data Json?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
resolvedAt DateTime?
|
|
|
|
@@unique([provider, kind, externalCode])
|
|
@@index([resolvedAt])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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.
|
|
allocatorAdminId String? @map("allocatorAdminUserId")
|
|
allocatorAdmin Admin? @relation("AllocationAuthor", fields: [allocatorAdminId], 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)
|
|
|
|
sentByAdminId String? @map("sentByAdminUserId")
|
|
sentByAdmin Admin? @relation("SmsAuthor", fields: [sentByAdminId], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model Notification {
|
|
id String @id @default(cuid())
|
|
title String
|
|
body String
|
|
audience MessageAudience @default(ALL_CUSTOMERS)
|
|
|
|
sentByAdminId String? @map("sentByAdminUserId")
|
|
sentByAdmin Admin? @relation("NotificationAuthor", fields: [sentByAdminId], 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?
|
|
|
|
uploadedByAdminId String? @map("uploadedByAdminUserId")
|
|
uploadedByAdmin Admin? @relation("FileUploader", fields: [uploadedByAdminId], 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
|
|
}
|