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
@@ -0,0 +1,210 @@
-- ============================================================================
-- Preparation for the legacy data migration — PURELY ADDITIVE.
--
-- Owner decisions of 2026-10-06 and 2026-10-07, recorded in
-- docs/DATA-MIGRATION-MAP.md §5.2, §5.5, §10, §10.4, §10.5.5.
--
-- Nothing here drops, renames or retypes an existing column. Every statement
-- either removes a constraint or adds a new column/table, so code that works
-- today keeps working. Verified NOT touched:
-- * Customer.accounting{Gold,Rial}Balance / accountingBalanceAt (D27)
-- * Product.accountingStock{Weight,Count} / accountingStockAt (D27)
-- * ProductVariant.imageId (PROTECTED.md §B)
-- * AccountingLink / AccountingOutbox / AccountingIssue
-- * SmsMessage and the SMS module
-- These are target-only capabilities under PROTECTED.md §G ("a keep-out
-- list"), and the owner confirmed on 2026-10-06 that their data is real.
--
-- D15: idempotent (IF NOT EXISTS / duplicate_object guards) because this runs
-- under BOTH ledgers — the Prisma CLI in development and the app's own runner
-- (src/prisma/migrate.ts) in the deploy container, which wraps each file in a
-- transaction. The runner's splitSqlStatements() handles $$-quoted blocks
-- (migrate.ts:83-91), so the DO blocks below are split correctly.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. Customer.nationalCode becomes OPTIONAL
--
-- Source reality (docs/migration-precheck.sql, run 2026-10-06 on the live
-- `oapi` MySQL schema): empty_ncode = 1595 of n_customers = 1595. NOT ONE of
-- the 1,616 legacy customers has a national code. dup_ncode_groups = 0.
--
-- Owner ruling 2026-10-07: "ساختن کد ملی فیک تخلف دیتایی است و مانع از
-- احراز هویت‌های قانونی بعدی می‌شود" — fabricating one is a data violation, so
-- the column must be nullable. Without this the customer migration cannot
-- insert a single row.
--
-- This also RETIRES three existing workarounds that only exist because the
-- column is required. They write the MOBILE NUMBER into the national-code
-- field, i.e. they store wrong data today:
-- * admin-customers.service.ts:625 nationalCode: mobile
-- comment: "schema requires it and it is unique; singleCustomer.vue
-- collects no ncode"
-- * admin-customers.service.ts:685 nationalCode: nationalCode || mobile
-- * customer-auth.service.ts:258 ... || admin.mobile
-- Removing those fallbacks is a SERVICE-LAYER change, deliberately NOT done
-- here: this migration only makes it possible. Until they are removed the
-- behaviour is exactly as it is today.
--
-- The UNIQUE index "Customer_nationalCode_key" (first/migration.sql:304) is
-- left in place. In PostgreSQL NULLs are not equal to each other, so any
-- number of rows may hold NULL under a unique index — which is precisely why
-- the migration tool must write NULL and never the empty string: '' would
-- collide on the second row (see §10.4).
--
-- DROP NOT NULL takes only a brief ACCESS EXCLUSIVE lock on the catalog; it
-- does not rewrite the table and does not scan the data.
ALTER TABLE "Customer" ALTER COLUMN "nationalCode" DROP NOT NULL;
COMMENT ON COLUMN "Customer"."nationalCode" IS
'Legacy customers.ncode. OPTIONAL: no legacy customer has one (1595/1595 empty as of 2026-10-06), and fabricating a value is a data violation (owner, 2026-10-07). Still UNIQUE — write NULL, never '''', because NULLs do not collide under a unique index but empty strings do.';
-- ----------------------------------------------------------------------------
-- 2. Customer.birthDate — new, nullable, unread until backfilled
--
-- Legacy `customers.birth_date timestamp NULL` (oapi dump line 484), populated
-- on 917 of 1,595 live rows (~57%). Owner ruling 2026-10-07: migrate it —
-- "نرخ پرشدگی بالای ۵۵٪ است و داده برای کمپین‌های وفاداری و پیامکی کاربرد
-- مستقیم دارد".
--
-- Two sibling columns were deliberately NOT ported, same ruling:
-- * marriage_date (5 rows) — "۵ ردیف توجیه معماری ندارد"
-- * token_mobile (99 rows) — "توکن‌ها اغلب منقضی شده‌اند · زباله دیتایی"
--
-- ⚠ THE COLUMN IS CREATED EMPTY ON PURPOSE. Do not backfill it until the
-- calendar question is settled (§5.9): legacy stores some dates as JALALI
-- STRINGS elsewhere (cashes.recive_date is varchar(255), 88 of 89 values
-- starting "14…"), so birth_date may hold Jalali years mis-entered into a
-- timestamp. Copying those unchanged would silently produce 917 WRONG
-- birthdays, which for an SMS campaign is worse than having none.
-- docs/migration-precheck-3.sql answers this.
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "birthDate" TIMESTAMP(3);
COMMENT ON COLUMN "Customer"."birthDate" IS
'Legacy customers.birth_date (917 of 1595 rows populated). Created empty: verify Jalali-vs-Gregorian with docs/migration-precheck-3.sql BEFORE backfilling — legacy stores dates as Jalali strings in other tables.';
-- ----------------------------------------------------------------------------
-- 3. Rejection trail for document verification
--
-- Legacy has THREE states per verification step, not two
-- (MajorCustomerController.php:106-135): 1 = awaiting review, 2 = approved,
-- 3 = REJECTED. The target's VerificationStep enum has no rejected state, so
-- 14 already-rejected customers had nowhere to go.
--
-- Owner ruling 2026-10-07: mapping them to "awaiting" is "کاملاً مردود" —
-- it would put closed cases back in the review queue and create duplicate
-- work. REJECTED is added to the enum (statement 4 below) and these two
-- columns carry the reason.
--
-- Legacy records NO reason: all 49 `customers` columns were checked and none
-- is a reason/note/description field, and on rejection the server sends only
-- a FIXED SMS ("اطلاعات شما تایید نشده است. لطفا با پیشتیبانی تماس حاصل
-- فرمایید") with no cause stored anywhere. So for those 14 rows the reason is
-- unrecoverable; the owner asked the tool to write an explicit sentinel rather
-- than NULL, so support knows WHY it is blank:
-- رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده
-- That text is written by the migration tool, not by this migration, and only
-- WHERE verificationRejectionReason IS NULL so a re-run can never overwrite a
-- real reason entered in the new system (§5.5).
--
-- verificationRejectedAt stays NULL for the 14 legacy rows: legacy did not
-- record a rejection timestamp either.
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectionReason" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectedAt" TIMESTAMP(3);
COMMENT ON COLUMN "Customer"."verificationRejectionReason" IS
'Why document verification was rejected. Legacy stored no reason (only a fixed SMS), so rows migrated from henza carry the sentinel «رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده». Write only WHERE this IS NULL so a re-run never overwrites a real reason.';
COMMENT ON COLUMN "Customer"."verificationRejectedAt" IS
'When verification was rejected. NULL for rows migrated from henza — legacy did not record a rejection timestamp.';
-- ----------------------------------------------------------------------------
-- 4. VerificationStep gains REJECTED
--
-- ADD VALUE is additive: existing rows and existing queries are unaffected.
-- IF NOT EXISTS makes a re-run a no-op.
--
-- ⚠ CONSUMER AUDIT STILL OWED. Every read of VerificationStep must be checked
-- for a Persian label / switch that does not yet know this value, or the admin
-- panel will render a blank cell. Flagged in §10.5.5 as a separate task; it is
-- a display gap, not a data risk, and nothing writes REJECTED until the
-- migration tool runs.
--
-- Postgres note: before v12 ADD VALUE could not run inside a transaction, and
-- the app's own runner wraps each file in one (migrate.ts:198). This project
-- targets a modern Postgres where that restriction is lifted. If the deploy
-- container ever errors with "ALTER TYPE ... ADD VALUE cannot be executed
-- inside a transaction block", this statement must move to its own file.
DO $$
BEGIN
ALTER TYPE "VerificationStep" ADD VALUE IF NOT EXISTS 'REJECTED';
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ----------------------------------------------------------------------------
-- 5. CustomerFavorite — the legacy `fav` table finally gets a home
--
-- Legacy `fav` holds 251 rows (customer_id, product_id) and the target had NO
-- model for it, so every customer's saved products would have been lost.
-- Owner ruling 2026-10-06: they matter. docs/PWA-PORT-PLAN.md reached the same
-- conclusion independently.
--
-- Legacy `fav` has only two columns and NO primary key, so duplicate pairs are
-- possible in principle. The composite primary key here closes that, and the
-- precheck confirmed fav_duplicate_pairs = 0, so no de-duplication is needed —
-- but the tool should still SELECT DISTINCT, since the source cannot enforce it.
--
-- 14 of the 251 rows point at a customer that no longer exists
-- (fav.customer_id orphans). Those must be SKIPPED by the tool; the foreign
-- keys below would reject them anyway.
--
-- ON DELETE CASCADE on both sides: a favourite is a pure join row with no
-- meaning once either end is gone. This differs from ProductVariant.imageId,
-- which uses SetNull precisely so a deleted image never cascades into order
-- history — the distinction is deliberate.
--
-- Legacy also keeps a denormalised `product.count_fav` counter. It is NOT
-- recreated as a column; derive it from this table (§5.2 group B) rather than
-- showing a stale or zero value.
CREATE TABLE IF NOT EXISTS "CustomerFavorite" (
"customerId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerFavorite_pkey" PRIMARY KEY ("customerId", "productId")
);
-- The PK already indexes (customerId, productId) left-to-right, so "my
-- favourites" needs no extra index. This one serves the reverse lookup —
-- "how many customers favourited this product" — which is how count_fav is
-- derived.
CREATE INDEX IF NOT EXISTS "CustomerFavorite_productId_idx"
ON "CustomerFavorite"("productId");
DO $$
BEGIN
ALTER TABLE "CustomerFavorite"
ADD CONSTRAINT "CustomerFavorite_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE "CustomerFavorite"
ADD CONSTRAINT "CustomerFavorite_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
COMMENT ON TABLE "CustomerFavorite" IS
'Legacy `fav` (251 rows). Composite PK replaces the source table''s missing primary key. Derive product.count_fav from here rather than storing a counter.';
+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 {
@@ -230,7 +230,11 @@ export class AccountingSyncService {
}
private customerPayload(
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string },
// nationalCode is nullable since 2026-10-07 (no legacy customer has one —
// docs/DATA-MIGRATION-MAP.md §10.4). validNationalCode() already takes
// `string | null` and maps empty/invalid to null, so the providers below
// keep receiving exactly what they received before.
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string | null },
group: ExternalGroup | null,
): CustomerPayload {
const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);