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:
co-authored by
Claude Opus 5.5
parent
19d490e790
commit
45633b5aa0
@@ -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.';
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user