feat(db): additive prep migration for the henza data import

Purely additive; nothing dropped, renamed or retyped. Owner rulings of
2026-10-06/07, recorded in docs/DATA-MIGRATION-MAP.md.

- Customer.nationalCode is now optional. All 1,595 live legacy customers
  have none, and fabricating one is a data violation. Still @unique:
  NULLs do not collide in Postgres, so the import must write NULL, not ''.
- Customer.birthDate added, empty. Must not be backfilled until the
  Jalali-vs-Gregorian check (precheck-3) is in.
- VerificationStep.REJECTED plus verificationRejectionReason/RejectedAt.
  Legacy has a third "rejected" state that the enum could not represent.
- CustomerFavorite for legacy `fav` (251 rows), which had no home.

accounting-sync customerPayload() now takes `nationalCode: string | null`.
validNationalCode() already handled null, so Tahesab/Zarrin receive
exactly what they did before. Caught by tsc after the column change.

Not touched: accounting* columns, ProductVariant.imageId, the
Accounting* tables, SMS (PROTECTED.md §B, §G).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-07 17:37:12 +03:30
co-authored by Claude Opus 5.5
parent 19d490e790
commit 45633b5aa0
3 changed files with 282 additions and 2 deletions
+67 -1
View File
@@ -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
@@ -367,7 +374,18 @@ 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])
@@ -429,6 +447,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.
@@ -457,12 +499,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
@@ -579,6 +644,7 @@ model Product {
comments Comment[]
promotions Promotion[]
allocationItems BranchAllocationItem[]
favoritedBy CustomerFavorite[]
}
model ProductImage {