Three real bugs found and fixed while wiring up product images end-to-end: 1. src/prisma/migrate.ts only discovered flat prisma/migrations/<name>.sql files, never Prisma's own folder-per-migration layout (<timestamp>_<name>/migration.sql). Every migration created the normal way (via 'prisma migrate dev') was silently skipped; only a hand-added flat duplicate (prisma/migrations/0001_init.sql, now removed) ever ran. This meant schema.prisma had drifted well ahead of what any real deploy database actually had (missing columns on City, Customer, Branch, Product, several new tables). Fixed to discover both layouts; the drift is closed with a new 20260914165255_sync_schema_drift migration. 2. Neither nginx.conf (production) nor vite.config.ts (dev) proxied /uploads/ to the API — only /api/. UploadedFile/ProductImage URLs (/uploads/<file>, served by main.ts's useStaticAssets OUTSIDE the /api prefix) 404'd through both. Both now proxy /uploads/ the same way /api/ already was. 3. docker-compose.yml's api service had no volume for storage/uploads, so every uploaded file — and by extension every ProductImage row pointing at one — was lost on the next container recreate/redeploy. Added a named volume. Also: main.ts's static file serving now sets Content-Type: image/avif explicitly (serve-static's bundled mime table predates .avif and served it as application/octet-stream; browsers rendered it anyway by sniffing, but a stricter client would not have). Product photos: copied 15 real jewelry photos into the uploads volume, registered them as UploadedFile rows, and assigned each of the 5 existing Product rows a randomly picked one (duplicates allowed, per request), replacing their /img/no-items.png placeholder ProductImage row. Verified end-to-end in a real browser against the real containerized API+DB: logged in, opened the admin product list, confirmed all 5 rows render their photo (naturalWidth/Height populated, network 200s, no console errors). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
858 lines
34 KiB
Plaintext
858 lines
34 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.
|
|
enum CustomerKind {
|
|
SALES_AGENT // 1 کارشناس فروش
|
|
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
|
|
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
|
|
BANKER // 4 بنکدار
|
|
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
|
|
}
|
|
|
|
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())
|
|
|
|
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])
|
|
}
|
|
|
|
model Customer {
|
|
id String @id @default(cuid())
|
|
fullName String
|
|
mobile String @unique
|
|
nationalCode String @unique
|
|
passwordHash String
|
|
cityId String?
|
|
city City? @relation(fields: [cityId], references: [id])
|
|
|
|
/// 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
|
|
|
|
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[]
|
|
|
|
@@index([referredByAgentId])
|
|
}
|
|
|
|
model CustomerAddress {
|
|
id String @id @default(cuid())
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
cityId String?
|
|
city City? @relation(fields: [cityId], references: [id])
|
|
addressLine String
|
|
isDefault Boolean @default(false)
|
|
}
|
|
|
|
model Branch {
|
|
id String @id @default(cuid())
|
|
name String
|
|
/// 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)
|
|
}
|
|
|
|
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)
|
|
|
|
orderItems OrderItem[]
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Orders — stock orders (debt-bearing)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model Order {
|
|
id String @id @default(cuid())
|
|
orderNumber String @unique
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
branchId String?
|
|
branch Branch? @relation(fields: [branchId], references: [id])
|
|
/// 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")
|
|
|
|
@@index([purpose])
|
|
}
|