Merge release/delivery (IAM single sign-on) into the triage work
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.
Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
release bundle): rebuilt from the merged source instead of picking a
side.
Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
the identity-service stand-ins in three test files lacked it. 15 tests
failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
never got `platform` (documented body: phone, host, text, platform).
relayConfig() now returns it ("default" → null) and the request carries
it. sms.service.spec now passes.
All 249 API tests pass; the three front-ends type-check and build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+233
-2
@@ -56,6 +56,13 @@ enum VerificationStep {
|
||||
STEP_ONE_SUBMITTED
|
||||
STEP_TWO_SUBMITTED
|
||||
VERIFIED
|
||||
|
||||
/// Legacy had THREE states per step, not two
|
||||
/// (MajorCustomerController.php:106-135): 1 = awaiting, 2 = approved,
|
||||
/// 3 = REJECTED. Mapping rejected customers to UNVERIFIED would put closed
|
||||
/// cases back in the review queue, so the owner ruled it out (2026-10-07).
|
||||
/// 14 legacy rows land here; see Customer.verificationRejectionReason.
|
||||
REJECTED
|
||||
}
|
||||
|
||||
/// Legacy kept retail and wholesale orders in two SEPARATE tables
|
||||
@@ -111,10 +118,15 @@ enum LedgerSourceType {
|
||||
// Identity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Legacy `cities` is a tree (country > province > city > district) and so is
|
||||
/// this: a city hangs under its `parent`. In the admin pickers a top-level row
|
||||
/// (the country) is a heading, not selectable (henza_admin citiesModel.js:38).
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
parentId String?
|
||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children City[] @relation("CityTree")
|
||||
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
||||
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
||||
percent Decimal? @db.Decimal(6, 3)
|
||||
@@ -124,6 +136,8 @@ model City {
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
salesExpertProfiles SalesExpertProfile[]
|
||||
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
@@ -172,6 +186,8 @@ model AdminUser {
|
||||
memberCode String? // legacy mcode
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
/// Orders credited to this sales expert — see Order.salesAgentId.
|
||||
creditedOrders Order[] @relation("OrderSalesAgent")
|
||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||
postedLedgerEntries LedgerEntry[]
|
||||
@@ -367,11 +383,32 @@ model Customer {
|
||||
lastName String?
|
||||
|
||||
mobile String @unique
|
||||
nationalCode String @unique
|
||||
/// Legacy customers.ncode. OPTIONAL since 2026-10-07: not one of the 1,616
|
||||
/// legacy customers has a national code (1595/1595 empty), and fabricating
|
||||
/// one is a data violation that would block later legal identity checks
|
||||
/// (owner ruling). Still @unique — in PostgreSQL NULLs do not collide, so
|
||||
/// the migration tool must write NULL and never '', which would collide on
|
||||
/// the second row.
|
||||
///
|
||||
/// Three service-layer workarounds exist ONLY because this used to be
|
||||
/// required, and they store the MOBILE NUMBER here:
|
||||
/// admin-customers.service.ts:625,685 and customer-auth.service.ts:258.
|
||||
/// Retiring them is a separate change; see docs/DATA-MIGRATION-MAP.md §10.4.
|
||||
nationalCode String? @unique
|
||||
passwordHash String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
/// Legacy customers.new_city — the city name exactly as the customer typed it
|
||||
/// at app sign-up (Major/CustomerController.php:36). Kept verbatim because
|
||||
/// the legacy city table has only 15 rows, so the typed name is often the only
|
||||
/// record of where the customer is (172 customers; 43 match no known city).
|
||||
/// cityId is set alongside it only on an exact, ي/ك-normalised name match.
|
||||
/// Legacy sign-up also forced city_id = 5 (Tehran) on everyone
|
||||
/// (Major/CustomerController.php:21), so for those rows the typed name, not
|
||||
/// cityId, is the truth. Owner ruling 2026-10-07: keep both.
|
||||
cityNameRaw String?
|
||||
|
||||
/// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the
|
||||
/// BankerList / ShopkeeperList screens filter on.
|
||||
kind CustomerKind @default(APP_SHOPKEEPER)
|
||||
@@ -393,6 +430,9 @@ model Customer {
|
||||
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
||||
/// ordinary customer.
|
||||
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
|
||||
/// Legacy orders that named THIS customer as their referrer — see
|
||||
/// Order.referrerCustomerId. History only.
|
||||
referredOrders Order[] @relation("OrderReferrerCustomer")
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
@@ -400,6 +440,14 @@ model Customer {
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
/// Balance as last read from the accounting software (D27), in the app's own
|
||||
/// sign convention (positive = owes). Shown to the customer when the
|
||||
/// integration is on; the credit-limit check still uses goldDebtGrams above.
|
||||
/// Null until the first successful sync.
|
||||
accountingGoldBalance Decimal? @db.Decimal(14, 4)
|
||||
accountingRialBalance Decimal? @db.Decimal(20, 2)
|
||||
accountingBalanceAt DateTime?
|
||||
|
||||
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
|
||||
/// ADDED to the variant's making charge when pricing an order line
|
||||
/// (henza_pwa src/Mixins/product/index.ts:69-75:
|
||||
@@ -421,6 +469,30 @@ model Customer {
|
||||
businessLicenseUrl String?
|
||||
shopImageUrl String?
|
||||
|
||||
/// Why verification was rejected (VerificationStep.REJECTED).
|
||||
///
|
||||
/// Legacy recorded NO reason: none of its 49 `customers` columns is a
|
||||
/// reason/note field, and on rejection it only sent a fixed SMS. So the 14
|
||||
/// rows migrated from henza carry the sentinel
|
||||
/// «رد شده در سیستم قدیم — فاقد شرح علت ثبتشده» instead of NULL, so support
|
||||
/// knows why it is blank (owner ruling 2026-10-07). The migration tool
|
||||
/// writes it only WHERE this IS NULL, so a re-run cannot overwrite a real
|
||||
/// reason entered in the new system.
|
||||
verificationRejectionReason String?
|
||||
|
||||
/// NULL for rows migrated from henza — legacy recorded no rejection time.
|
||||
verificationRejectedAt DateTime?
|
||||
|
||||
/// Legacy customers.birth_date, populated on 917 of 1,595 rows. Kept for the
|
||||
/// birthday SMS / loyalty campaigns (owner ruling 2026-10-07).
|
||||
///
|
||||
/// ⚠ Legacy stores dates as JALALI STRINGS in other tables
|
||||
/// (cashes.recive_date is varchar, 88/89 values starting "14…"), so this
|
||||
/// timestamp may hold mis-entered Jalali years. Confirm the calendar with
|
||||
/// docs/migration-precheck-3.sql BEFORE backfilling: copying them unchanged
|
||||
/// would produce 917 wrong birthdays, worse for an SMS campaign than none.
|
||||
birthDate DateTime?
|
||||
|
||||
/// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent
|
||||
/// form. Legacy stored an FK to its Image table and read it back as a nested
|
||||
/// `image: { id, image }`, so this is a relation, not a bare URL string.
|
||||
@@ -449,12 +521,35 @@ model Customer {
|
||||
notifications NotificationRecipient[]
|
||||
activityLogs CustomerActivityLog[]
|
||||
otpChallenges CustomerOtp[]
|
||||
favorites CustomerFavorite[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
@@index([firstName])
|
||||
@@index([lastName])
|
||||
}
|
||||
|
||||
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
|
||||
/// so every customer's favourites would have been lost in the migration; the
|
||||
/// owner confirmed on 2026-10-06 that they matter.
|
||||
///
|
||||
/// Legacy `fav` has two columns and NO primary key. The composite key here
|
||||
/// supplies the constraint the source lacks (the precheck found 0 duplicate
|
||||
/// pairs, but the tool should still SELECT DISTINCT). 14 of the 251 rows point
|
||||
/// at a deleted customer and must be skipped.
|
||||
///
|
||||
/// Legacy's denormalised `product.count_fav` counter is deliberately NOT
|
||||
/// recreated as a column — derive it from this table.
|
||||
model CustomerFavorite {
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([customerId, productId])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model CustomerAddress {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
@@ -557,12 +652,21 @@ model Product {
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// Stock of the linked کارساخته as last read from the accounting software
|
||||
/// (D27). Weight for gram-type items, count for count-type ones — whichever
|
||||
/// the software reports. At zero or below the product is unavailable in the
|
||||
/// app (pre-order variants excepted). Null = not linked or never synced.
|
||||
accountingStockWeight Decimal? @db.Decimal(14, 4)
|
||||
accountingStockCount Decimal? @db.Decimal(14, 4)
|
||||
accountingStockAt DateTime?
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
comments Comment[]
|
||||
promotions Promotion[]
|
||||
allocationItems BranchAllocationItem[]
|
||||
favoritedBy CustomerFavorite[]
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
@@ -644,6 +748,22 @@ model Order {
|
||||
isSuspendedOverCreditLimit Boolean @default(false)
|
||||
deliveredAt DateTime? // setting this is what triggers debt recognition
|
||||
|
||||
/// The sales expert this order is credited to, fixed when it is placed:
|
||||
/// the customer's referring expert AT THAT MOMENT. Legacy `orders.reagent_id`,
|
||||
/// set once at creation (ApiMobileController.php:601) and never moved, so a
|
||||
/// customer changing expert later does not move past sales or commission
|
||||
/// (owner ruling 2026-10-08). «میزان فروش» counts from here, never from the
|
||||
/// customer's current referrer.
|
||||
salesAgentId String?
|
||||
salesAgent AdminUser? @relation("OrderSalesAgent", fields: [salesAgentId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// History only: a legacy order whose `reagent_id` named a customer who is
|
||||
/// not a sales expert (one such customer, 17 orders in 2022). Legacy counted
|
||||
/// those orders in that customer's own «میزان فروش»; this keeps that figure
|
||||
/// exact. Never set for a new order.
|
||||
referrerCustomerId String?
|
||||
referrerCustomer Customer? @relation("OrderReferrerCustomer", fields: [referrerCustomerId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -654,6 +774,8 @@ model Order {
|
||||
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
@@index([salesAgentId])
|
||||
@@index([referrerCustomerId])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
@@ -911,6 +1033,115 @@ model Setting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting integration (D27) — the shop's accounting software, Tahesab or
|
||||
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
|
||||
// `accounting.config` (never served by the generic `const` routes). Plan:
|
||||
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
//
|
||||
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
|
||||
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
|
||||
// switching provider and back loses nothing.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AccountingEntityType {
|
||||
CUSTOMER
|
||||
PRODUCT
|
||||
}
|
||||
|
||||
/// One app row ↔ one record in the accounting software (customer account or
|
||||
/// کارساخته).
|
||||
model AccountingLink {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
entityType AccountingEntityType
|
||||
entityId String
|
||||
/// The record's code in the accounting software (Tahesab Moshtari_Code /
|
||||
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
|
||||
externalCode String
|
||||
externalName String?
|
||||
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
|
||||
/// row created from the accounting record), PUSHED (accounting record created
|
||||
/// from the app row) or MANUAL (an admin typed the code).
|
||||
origin String
|
||||
/// The record as last read, for display only.
|
||||
snapshot Json?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([provider, entityType, entityId])
|
||||
@@unique([provider, entityType, externalCode])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
enum AccountingOutboxKind {
|
||||
CUSTOMER_PUSH
|
||||
PRODUCT_PUSH
|
||||
ORDER_DOCUMENT
|
||||
ORDER_DOCUMENT_DELETE
|
||||
}
|
||||
|
||||
enum AccountingOutboxStatus {
|
||||
PENDING
|
||||
/// A call was in flight. Found at startup, it means the process died mid-call
|
||||
/// and the outcome is unknown — see AccountingOutboxWorker.
|
||||
SENDING
|
||||
SENT
|
||||
FAILED
|
||||
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
|
||||
/// idempotency key, so a blind retry could post the document twice).
|
||||
NEEDS_REVIEW
|
||||
/// Closed by an admin from the log screen.
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
/// Work to push to the accounting software. Written in the SAME transaction as
|
||||
/// the change that causes it, so an outage of the accounting software never
|
||||
/// loses an event and never blocks the app.
|
||||
model AccountingOutbox {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind AccountingOutboxKind
|
||||
entityId String
|
||||
status AccountingOutboxStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastError String?
|
||||
/// What the accounting software returned as the document/record id.
|
||||
externalRef String?
|
||||
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
|
||||
/// line), so a failure resumes where it stopped.
|
||||
progress Json?
|
||||
/// Last request/response, for the log screen. Never contains the token.
|
||||
lastRequest Json?
|
||||
lastResponse Json?
|
||||
sentAt DateTime?
|
||||
resolvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([kind, entityId])
|
||||
}
|
||||
|
||||
/// Something a pull found that needs a human: an account with no mobile, two
|
||||
/// accounts sharing one mobile, a کارساخته group with no category mapping.
|
||||
model AccountingIssue {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind String
|
||||
externalCode String
|
||||
entityId String?
|
||||
message String
|
||||
data Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
resolvedAt DateTime?
|
||||
|
||||
@@unique([provider, kind, externalCode])
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Promotions / discounts — legacy views/promotion/*, served by
|
||||
// PromotionController (`product/variant/promotion`). The redesigned schema had
|
||||
|
||||
Reference in New Issue
Block a user