Files
tehran_tala/apps/api/prisma/schema.prisma
T
s.esmaeiliandClaude Opus 5 8133a2bdf4 parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.

D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.

The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.

D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).

update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.

SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.

Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.

Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.

Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).

[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:08:50 +03:30

992 lines
40 KiB
Plaintext

// Gold Banking System — core schema
// Canonical accounting model: legacy "System A" gram ledger (see migration notes).
// The external "Tahesab" service is retired — LedgerEntry is the sole source of truth.
// All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math.
generator client {
provider = "prisma-client-js"
// native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan)
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─────────────────────────────────────────────────────────────────────────
// Enums
// ─────────────────────────────────────────────────────────────────────────
enum AdminRole {
SUPER_ADMIN
SYSTEM_APPROVER // legacy: تایید مدیر سیستم
SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent"
WORKSHOP_SUPERVISOR
ACCOUNTANT
}
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
///
/// SALES_AGENT is a LEGACY value only: legacy stored a sales agent as a
/// Customer row with type=1, but the redesigned system models a sales agent
/// as an AdminUser (role SALES_EXPERT) instead — see admin-users.service.ts
/// and admin-customers.service.ts's listReagents()/create(). No Customer row
/// should be created with kind SALES_AGENT going forward; the value is kept
/// only so legacyTypeToKind()/kindToLegacyType() stay total functions and any
/// already-migrated data keeps a readable label.
///
/// RETAIL marks a "تک" (single/retail) customer — legacy kept these in an
/// entirely separate table (SingleCustomer/`login`) that could never contain
/// a wholesale/agent/shopkeeper/banker row. The redesigned schema folded both
/// into one Customer model, so this value is what listSingle() now filters on
/// to reproduce that same separation.
enum CustomerKind {
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
BANKER // 4 بنکدار
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
RETAIL // مشتری تک — legacy SingleCustomer
}
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
}
/// Legacy kept retail and wholesale orders in two SEPARATE tables
/// (orders_orders vs orders). The redesigned schema has one Order model, so the
/// distinction lives here instead.
enum OrderChannel {
RETAIL // تک فروشی
WHOLESALE // عمده فروشی
}
enum OrderStatus {
SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement
PLACED
ADMIN_APPROVED
SOURCING
PREPARING
DELIVERED_TO_AGENT
DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4
CANCELLED
}
enum PreOrderStatus {
PLACED
ADMIN_APPROVED
WORKSHOP_APPROVED
READY_TO_SHIP
DELIVERED
CANCELLED
}
enum LedgerCurrency {
GOLD_GRAM
RIAL
}
enum LedgerDirection {
DEBIT // increases what the customer owes
CREDIT // decreases what the customer owes
}
enum LedgerSourceType {
ORDER_STOCK
ORDER_PRE_ORDER
REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750
REPAYMENT_TRANSFER
REPAYMENT_CHEQUE
REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry
WALLET_BONUS
ADJUSTMENT
}
// ─────────────────────────────────────────────────────────────────────────
// Identity
// ─────────────────────────────────────────────────────────────────────────
model City {
id String @id @default(cuid())
name String
province String?
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
percent Decimal? @db.Decimal(6, 3)
percentAux Decimal? @db.Decimal(6, 3)
customers Customer[]
addresses CustomerAddress[]
branches Branch[]
preOrders PreOrder[]
}
model AdminUser {
id String @id @default(cuid())
fullName 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")
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
}
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])
}