// 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) 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]) // 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 }