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:
s.esmaeili
2026-10-10 09:03:53 +03:30
co-authored by Claude Opus 5.5
315 changed files with 82919 additions and 7020 deletions
+71865 -6435
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
-- Accounting integration (D27): Tahesab / Zarrin, chosen in the admin panel.
-- Plan: docs/ACCOUNTING-INTEGRATION-PLAN.md.
--
-- Adds the link table (app row <-> accounting record), the outbox the push
-- jobs run from, the issue list pulls report into, and the cached balance and
-- stock columns read back from the accounting software. Nothing existing
-- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches.
-- CreateEnum
CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT');
-- CreateEnum
CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE');
-- CreateEnum
CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED');
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3),
ADD COLUMN "accountingGoldBalance" DECIMAL(14,4),
ADD COLUMN "accountingRialBalance" DECIMAL(20,2);
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3),
ADD COLUMN "accountingStockCount" DECIMAL(14,4),
ADD COLUMN "accountingStockWeight" DECIMAL(14,4);
-- CreateTable
CREATE TABLE "AccountingLink" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"entityType" "AccountingEntityType" NOT NULL,
"entityId" TEXT NOT NULL,
"externalCode" TEXT NOT NULL,
"externalName" TEXT,
"origin" TEXT NOT NULL,
"snapshot" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingOutbox" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"kind" "AccountingOutboxKind" NOT NULL,
"entityId" TEXT NOT NULL,
"status" "AccountingOutboxStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastError" TEXT,
"externalRef" TEXT,
"progress" JSONB,
"lastRequest" JSONB,
"lastResponse" JSONB,
"sentAt" TIMESTAMP(3),
"resolvedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingIssue" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"externalCode" TEXT NOT NULL,
"entityId" TEXT,
"message" TEXT NOT NULL,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode");
-- CreateIndex
CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId");
-- CreateIndex
CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode");
-- The new admin screen's permission. The seeded super-admin user type (85,
-- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing
-- database has to be granted the new one here.
INSERT INTO "permission" ("rid", "userTypeId")
SELECT 'config.accounting', 85
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
ON CONFLICT DO NOTHING;
@@ -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.';
@@ -0,0 +1,22 @@
-- ============================================================================
-- Customer.cityNameRaw — PURELY ADDITIVE.
--
-- Owner ruling 2026-10-07 (docs/DATA-MIGRATION-MAP.md §5.9): keep the city a
-- customer typed at sign-up verbatim, and set cityId only on an exact match.
--
-- Legacy `customers.new_city` is free text (all 172 live values are names, 0
-- are ids). The legacy city table has only 15 rows, so 43 of those names match
-- no known city at all, and only ~72 match one of the 15 after ي/ك
-- normalisation. Dropping the text would lose where those customers are.
--
-- Legacy sign-up also forced city_id = 5 (تهران) on every new customer
-- (Major/CustomerController.php:21); 91 of the 92 customers with both values
-- carry that forced Tehran. For them the typed name is the truth.
--
-- D15: idempotent, runs under both migration ledgers.
-- ============================================================================
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "cityNameRaw" TEXT;
COMMENT ON COLUMN "Customer"."cityNameRaw" IS
'Legacy customers.new_city — city name as typed at sign-up, kept verbatim. cityId is set too only on an exact (ي/ك-normalised) match; legacy forced city_id=5 (Tehran) on sign-up, so prefer this text over that cityId.';
@@ -0,0 +1,31 @@
-- ============================================================================
-- Customer identity cleanup — data only, no schema change. Idempotent.
--
-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه».
--
-- 1. nationalCode held the MOBILE (or "<mobile>-<provider>-<code>") on every
-- customer created without a national code, because the column used to be
-- NOT NULL. It is optional since 20261007120000, and the code paths that
-- wrote those stand-ins are fixed in the same change as this file. The
-- stand-ins are cleared to NULL; a real national code is never touched.
-- Accounting is unaffected: it only ever received validNationalCode(),
-- which already mapped a mobile to null.
--
-- 2. passwordHash '' becomes the explicit "no password login" marker
-- (auth/password-hash.ts). Both are refused before bcrypt by
-- compareLocalHash, so nobody's ability to sign in changes.
-- ============================================================================
UPDATE "Customer"
SET "nationalCode" = NULL
WHERE "nationalCode" = ''
OR "nationalCode" = "mobile"
OR "nationalCode" LIKE "mobile" || '-%';
UPDATE "Customer" SET "passwordHash" = '!OTP_ONLY_NO_PASSWORD_LOGIN' WHERE "passwordHash" = '';
UPDATE "AdminUser" SET "passwordHash" = '!NO_PASSWORD_SET_YET' WHERE "passwordHash" = '';
-- The earlier comment named the wrong source table: `fav` belongs to the retail
-- shop; wholesale favourites come from `major_favorites` (DATA-MIGRATION-MAP §10.7).
COMMENT ON TABLE "CustomerFavorite" IS
'Legacy `major_favorites` (wholesale; deduplicated on import). Composite PK replaces the source table''s missing primary key. Derive a favourite count from here rather than storing a counter.';
@@ -0,0 +1,47 @@
-- ============================================================================
-- Order.salesAgentId / Order.referrerCustomerId — additive, idempotent.
--
-- Owner ruling 2026-10-08: keep legacy's rule exactly. An order is credited to
-- the expert who was the customer's referrer WHEN IT WAS PLACED (legacy
-- `orders.reagent_id`, set once at creation), so a customer moving to another
-- expert later never changes past sales or commission. The app used to credit
-- an order to the customer's CURRENT referrer, which disagreed with legacy for
-- 8 of 49 experts on the imported data.
--
-- No row is deleted and no existing column changes.
-- ============================================================================
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "salesAgentId" TEXT;
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "referrerCustomerId" TEXT;
DO $$ BEGIN
ALTER TABLE "Order" ADD CONSTRAINT "Order_salesAgentId_fkey"
FOREIGN KEY ("salesAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "Order" ADD CONSTRAINT "Order_referrerCustomerId_fkey"
FOREIGN KEY ("referrerCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS "Order_salesAgentId_idx" ON "Order"("salesAgentId");
CREATE INDEX IF NOT EXISTS "Order_referrerCustomerId_idx" ON "Order"("referrerCustomerId");
-- Orders placed in the new app before this column existed: the customer's
-- referrer today is the best record there is (these are recent). Orders
-- brought over from legacy (orderNumber 'H-<id>') are NOT guessed here — the
-- import writes their real `orders.reagent_id`.
UPDATE "Order" o
SET "salesAgentId" = c."referredByAgentId"
FROM "Customer" c
WHERE c."id" = o."customerId"
AND o."salesAgentId" IS NULL
AND c."referredByAgentId" IS NOT NULL
AND o."orderNumber" NOT LIKE 'H-%';
COMMENT ON COLUMN "Order"."salesAgentId" IS
'Sales expert credited with this order, fixed when it is placed (legacy orders.reagent_id). Never follows a later change of the customer''s referrer.';
COMMENT ON COLUMN "Order"."referrerCustomerId" IS
'History only: legacy orders.reagent_id when it named a customer who is not a sales expert. Never set for new orders.';
@@ -0,0 +1,9 @@
-- «اجرت» admin screen (owner ruling 2026-10-08): its permission for the
-- seeded super-admin user type. A fresh seed grants every rid in ALL_RIDS;
-- an existing database gets the new one here. Data only, idempotent.
-- The formula itself needs no row: until an admin approves one, the server
-- uses legacy's formula (pricing/wage-formula.ts LEGACY_WAGE_FORMULA).
INSERT INTO "permission" ("rid", "userTypeId")
SELECT 'config.wage', 85
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,14 @@
-- PERM-01 (owner approval 2026-10-08): legacy's two dashboard permissions
-- «آمار عمده فروشی» (dashboard.omde) and «آمار تک فروشی» (dashboard.tak), and
-- their tree parent "dashboard", became the single "dashboard.view" when the two
-- dashboards became one. A database seeded before that change still holds the
-- old rows: nothing reads them and the permission screen cannot show or revoke
-- them. Whoever held one keeps the dashboard through "dashboard.view", then the
-- old rows go. Data only, idempotent.
INSERT INTO "permission" ("rid", "userTypeId")
SELECT DISTINCT 'dashboard.view', "userTypeId"
FROM "permission"
WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak')
ON CONFLICT DO NOTHING;
DELETE FROM "permission" WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak');
@@ -0,0 +1,33 @@
-- PERS-02 (owner approval 2026-10-08): cities become a tree again, as in legacy
-- (country > province > city > district, henza `cities.parent`). The flat
-- `province` label is turned into parent rows, nothing is lost, then dropped.
ALTER TABLE "City" ADD COLUMN "parentId" TEXT;
-- One province row per distinct label, named as legacy names them
-- («استان تهران»), unless a city row already carries that name.
INSERT INTO "City" ("id", "name")
SELECT 'cprov' || substr(md5(p.pname), 1, 20), p.pname
FROM (
SELECT DISTINCT
CASE WHEN btrim("province") LIKE 'استان%' THEN btrim("province") ELSE 'استان ' || btrim("province") END AS pname
FROM "City"
WHERE "province" IS NOT NULL AND btrim("province") <> ''
) p
WHERE NOT EXISTS (SELECT 1 FROM "City" c WHERE c."name" = p.pname);
UPDATE "City" c
SET "parentId" = (
SELECT p."id" FROM "City" p
WHERE p."id" <> c."id"
AND p."name" = CASE WHEN btrim(c."province") LIKE 'استان%' THEN btrim(c."province") ELSE 'استان ' || btrim(c."province") END
ORDER BY p."id"
LIMIT 1
)
WHERE c."province" IS NOT NULL AND btrim(c."province") <> '';
ALTER TABLE "City" DROP COLUMN "province";
CREATE INDEX "City_parentId_idx" ON "City"("parentId");
ALTER TABLE "City" ADD CONSTRAINT "City_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+44 -22
View File
@@ -1607,6 +1607,9 @@ var ALL_RIDS = [
"comment.state",
"comment.view",
"comment.view.edit",
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
"config.accounting",
"config.wage",
"config.report",
"config.statics",
"config.statics.brand",
@@ -1742,28 +1745,7 @@ var PLACEHOLDER_PASSWORD_HASH = hashSync(DEV_PASSWORD, 10);
var DEV_TEST_PHONE = "09128081392";
var DEV_TEST_PHONE_PASSWORD_HASH = hashSync(DEV_TEST_PHONE, 10);
var PLACEHOLDER_PRODUCT_IMAGE = "/img/no-items.png";
async function main() {
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
prisma.city.upsert({ where: { id: "city-tehran" }, update: {}, create: { id: "city-tehran", name: "\u062A\u0647\u0631\u0627\u0646", province: "\u062A\u0647\u0631\u0627\u0646" } }),
prisma.city.upsert({ where: { id: "city-mashhad" }, update: {}, create: { id: "city-mashhad", name: "\u0645\u0634\u0647\u062F", province: "\u062E\u0631\u0627\u0633\u0627\u0646 \u0631\u0636\u0648\u06CC" } }),
prisma.city.upsert({ where: { id: "city-isfahan" }, update: {}, create: { id: "city-isfahan", name: "\u0627\u0635\u0641\u0647\u0627\u0646", province: "\u0627\u0635\u0641\u0647\u0627\u0646" } }),
prisma.city.upsert({ where: { id: "city-tabriz" }, update: {}, create: { id: "city-tabriz", name: "\u062A\u0628\u0631\u06CC\u0632", province: "\u0622\u0630\u0631\u0628\u0627\u06CC\u062C\u0627\u0646 \u0634\u0631\u0642\u06CC" } })
]);
const branchTehran = await prisma.branch.upsert({
where: { id: "branch-tehran-bazaar" },
update: {},
create: { id: "branch-tehran-bazaar", name: "\u0634\u0639\u0628\u0647 \u0628\u0627\u0632\u0627\u0631 \u0628\u0632\u0631\u06AF \u062A\u0647\u0631\u0627\u0646", cityId: tehran.id }
});
const branchMashhad = await prisma.branch.upsert({
where: { id: "branch-mashhad-main" },
update: {},
create: { id: "branch-mashhad-main", name: "\u0634\u0639\u0628\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0645\u0634\u0647\u062F", cityId: mashhad.id }
});
await prisma.workshop.upsert({
where: { id: "workshop-central" },
update: {},
create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" }
});
async function seedAccessControl() {
const userTypes = [
// Full access — every rid the routers gate.
{ id: 85, title: "\u0645\u062F\u06CC\u0631 \u0627\u0631\u0634\u062F", rids: ALL_RIDS },
@@ -1824,6 +1806,46 @@ async function main() {
skipDuplicates: true
});
}
}
function demoDataAllowed() {
if (process.env.SEED_DEMO_DATA?.trim() === "true") return true;
return process.env.NODE_ENV !== "production";
}
async function main() {
await seedAccessControl();
if (!demoDataAllowed()) {
console.log("seed: production \u2014 roles and permissions only; demo data skipped (set SEED_DEMO_DATA=true to force)");
return;
}
const city = (id, name, parentId) => prisma.city.upsert({ where: { id }, update: {}, create: { id, name, parentId } });
await city("city-iran", "\u0627\u06CC\u0631\u0627\u0646", null);
await Promise.all([
city("city-prov-tehran", "\u0627\u0633\u062A\u0627\u0646 \u062A\u0647\u0631\u0627\u0646", "city-iran"),
city("city-prov-khorasan-razavi", "\u0627\u0633\u062A\u0627\u0646 \u062E\u0631\u0627\u0633\u0627\u0646 \u0631\u0636\u0648\u06CC", "city-iran"),
city("city-prov-isfahan", "\u0627\u0633\u062A\u0627\u0646 \u0627\u0635\u0641\u0647\u0627\u0646", "city-iran"),
city("city-prov-east-azarbaijan", "\u0627\u0633\u062A\u0627\u0646 \u0622\u0630\u0631\u0628\u0627\u06CC\u062C\u0627\u0646 \u0634\u0631\u0642\u06CC", "city-iran")
]);
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
city("city-tehran", "\u062A\u0647\u0631\u0627\u0646", "city-prov-tehran"),
city("city-mashhad", "\u0645\u0634\u0647\u062F", "city-prov-khorasan-razavi"),
city("city-isfahan", "\u0627\u0635\u0641\u0647\u0627\u0646", "city-prov-isfahan"),
city("city-tabriz", "\u062A\u0628\u0631\u06CC\u0632", "city-prov-east-azarbaijan")
]);
const branchTehran = await prisma.branch.upsert({
where: { id: "branch-tehran-bazaar" },
update: {},
create: { id: "branch-tehran-bazaar", name: "\u0634\u0639\u0628\u0647 \u0628\u0627\u0632\u0627\u0631 \u0628\u0632\u0631\u06AF \u062A\u0647\u0631\u0627\u0646", cityId: tehran.id }
});
const branchMashhad = await prisma.branch.upsert({
where: { id: "branch-mashhad-main" },
update: {},
create: { id: "branch-mashhad-main", name: "\u0634\u0639\u0628\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0645\u0634\u0647\u062F", cityId: mashhad.id }
});
await prisma.workshop.upsert({
where: { id: "workshop-central" },
update: {},
create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" }
});
await prisma.adminUser.upsert({
where: { mobile: DEV_TEST_PHONE },
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
+9
View File
@@ -0,0 +1,9 @@
class PrismaClient {
constructor() {
throw new Error(
'@prisma/client/deno/edge did not initialize yet. Please run "prisma generate" and try to import it again.',
)
}
}
export { PrismaClient }
File diff suppressed because one or more lines are too long
+97 -3
View File
@@ -125,7 +125,7 @@ exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
exports.Prisma.CityScalarFieldEnum = {
id: 'id',
name: 'name',
province: 'province',
parentId: 'parentId',
percent: 'percent',
percentAux: 'percentAux'
};
@@ -203,6 +203,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
nationalCode: 'nationalCode',
passwordHash: 'passwordHash',
cityId: 'cityId',
cityNameRaw: 'cityNameRaw',
kind: 'kind',
phone: 'phone',
address: 'address',
@@ -214,11 +215,17 @@ exports.Prisma.CustomerScalarFieldEnum = {
goldCreditLimitGrams: 'goldCreditLimitGrams',
goldDebtGrams: 'goldDebtGrams',
fiatDebtRial: 'fiatDebtRial',
accountingGoldBalance: 'accountingGoldBalance',
accountingRialBalance: 'accountingRialBalance',
accountingBalanceAt: 'accountingBalanceAt',
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
verificationStep: 'verificationStep',
nationalIdImageUrl: 'nationalIdImageUrl',
businessLicenseUrl: 'businessLicenseUrl',
shopImageUrl: 'shopImageUrl',
verificationRejectionReason: 'verificationRejectionReason',
verificationRejectedAt: 'verificationRejectedAt',
birthDate: 'birthDate',
avatarFileId: 'avatarFileId',
canPlaceOrders: 'canPlaceOrders',
isActive: 'isActive',
@@ -227,6 +234,12 @@ exports.Prisma.CustomerScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.CustomerFavoriteScalarFieldEnum = {
customerId: 'customerId',
productId: 'productId',
createdAt: 'createdAt'
};
exports.Prisma.CustomerAddressScalarFieldEnum = {
id: 'id',
customerId: 'customerId',
@@ -290,7 +303,10 @@ exports.Prisma.ProductScalarFieldEnum = {
productCode: 'productCode',
showType: 'showType',
isActive: 'isActive',
createdAt: 'createdAt'
createdAt: 'createdAt',
accountingStockWeight: 'accountingStockWeight',
accountingStockCount: 'accountingStockCount',
accountingStockAt: 'accountingStockAt'
};
exports.Prisma.ProductImageScalarFieldEnum = {
@@ -326,6 +342,8 @@ exports.Prisma.OrderScalarFieldEnum = {
flatRialCharge: 'flatRialCharge',
isSuspendedOverCreditLimit: 'isSuspendedOverCreditLimit',
deliveredAt: 'deliveredAt',
salesAgentId: 'salesAgentId',
referrerCustomerId: 'referrerCustomerId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -453,6 +471,51 @@ exports.Prisma.SettingScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.AccountingLinkScalarFieldEnum = {
id: 'id',
provider: 'provider',
entityType: 'entityType',
entityId: 'entityId',
externalCode: 'externalCode',
externalName: 'externalName',
origin: 'origin',
snapshot: 'snapshot',
syncedAt: 'syncedAt',
createdAt: 'createdAt'
};
exports.Prisma.AccountingOutboxScalarFieldEnum = {
id: 'id',
provider: 'provider',
kind: 'kind',
entityId: 'entityId',
status: 'status',
attempts: 'attempts',
nextAttemptAt: 'nextAttemptAt',
lastError: 'lastError',
externalRef: 'externalRef',
progress: 'progress',
lastRequest: 'lastRequest',
lastResponse: 'lastResponse',
sentAt: 'sentAt',
resolvedById: 'resolvedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountingIssueScalarFieldEnum = {
id: 'id',
provider: 'provider',
kind: 'kind',
externalCode: 'externalCode',
entityId: 'entityId',
message: 'message',
data: 'data',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
resolvedAt: 'resolvedAt'
};
exports.Prisma.PromotionScalarFieldEnum = {
id: 'id',
title: 'title',
@@ -567,6 +630,11 @@ exports.Prisma.JsonNullValueInput = {
JsonNull: Prisma.JsonNull
};
exports.Prisma.NullableJsonNullValueInput = {
DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
@@ -603,7 +671,8 @@ exports.VerificationStep = exports.$Enums.VerificationStep = {
UNVERIFIED: 'UNVERIFIED',
STEP_ONE_SUBMITTED: 'STEP_ONE_SUBMITTED',
STEP_TWO_SUBMITTED: 'STEP_TWO_SUBMITTED',
VERIFIED: 'VERIFIED'
VERIFIED: 'VERIFIED',
REJECTED: 'REJECTED'
};
exports.ProductImageRole = exports.$Enums.ProductImageRole = {
@@ -665,6 +734,27 @@ exports.CommentStatus = exports.$Enums.CommentStatus = {
REJECTED: 'REJECTED'
};
exports.AccountingEntityType = exports.$Enums.AccountingEntityType = {
CUSTOMER: 'CUSTOMER',
PRODUCT: 'PRODUCT'
};
exports.AccountingOutboxKind = exports.$Enums.AccountingOutboxKind = {
CUSTOMER_PUSH: 'CUSTOMER_PUSH',
PRODUCT_PUSH: 'PRODUCT_PUSH',
ORDER_DOCUMENT: 'ORDER_DOCUMENT',
ORDER_DOCUMENT_DELETE: 'ORDER_DOCUMENT_DELETE'
};
exports.AccountingOutboxStatus = exports.$Enums.AccountingOutboxStatus = {
PENDING: 'PENDING',
SENDING: 'SENDING',
SENT: 'SENT',
FAILED: 'FAILED',
NEEDS_REVIEW: 'NEEDS_REVIEW',
RESOLVED: 'RESOLVED'
};
exports.PromotionScope = exports.$Enums.PromotionScope = {
PRODUCT: 'PRODUCT',
CATEGORY: 'CATEGORY',
@@ -693,6 +783,7 @@ exports.Prisma.ModelName = {
AdminSession: 'AdminSession',
SalesExpertProfile: 'SalesExpertProfile',
Customer: 'Customer',
CustomerFavorite: 'CustomerFavorite',
CustomerAddress: 'CustomerAddress',
Branch: 'Branch',
Workshop: 'Workshop',
@@ -715,6 +806,9 @@ exports.Prisma.ModelName = {
Permission: 'Permission',
Comment: 'Comment',
Setting: 'Setting',
AccountingLink: 'AccountingLink',
AccountingOutbox: 'AccountingOutbox',
AccountingIssue: 'AccountingIssue',
Promotion: 'Promotion',
PromotionExclusion: 'PromotionExclusion',
BranchAllocation: 'BranchAllocation',
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "prisma-client-e72c5c915e96796902913fde6ec160a337ad8fec683314696d8bd7a12ac26468",
"name": "prisma-client-9161dfb7f2a3ada2b72c27c7a91664048e7f0e5d5d265a0abff1e443711c8388",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
+233 -2
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
@@ -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
+97 -3
View File
@@ -125,7 +125,7 @@ exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
exports.Prisma.CityScalarFieldEnum = {
id: 'id',
name: 'name',
province: 'province',
parentId: 'parentId',
percent: 'percent',
percentAux: 'percentAux'
};
@@ -203,6 +203,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
nationalCode: 'nationalCode',
passwordHash: 'passwordHash',
cityId: 'cityId',
cityNameRaw: 'cityNameRaw',
kind: 'kind',
phone: 'phone',
address: 'address',
@@ -214,11 +215,17 @@ exports.Prisma.CustomerScalarFieldEnum = {
goldCreditLimitGrams: 'goldCreditLimitGrams',
goldDebtGrams: 'goldDebtGrams',
fiatDebtRial: 'fiatDebtRial',
accountingGoldBalance: 'accountingGoldBalance',
accountingRialBalance: 'accountingRialBalance',
accountingBalanceAt: 'accountingBalanceAt',
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
verificationStep: 'verificationStep',
nationalIdImageUrl: 'nationalIdImageUrl',
businessLicenseUrl: 'businessLicenseUrl',
shopImageUrl: 'shopImageUrl',
verificationRejectionReason: 'verificationRejectionReason',
verificationRejectedAt: 'verificationRejectedAt',
birthDate: 'birthDate',
avatarFileId: 'avatarFileId',
canPlaceOrders: 'canPlaceOrders',
isActive: 'isActive',
@@ -227,6 +234,12 @@ exports.Prisma.CustomerScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.CustomerFavoriteScalarFieldEnum = {
customerId: 'customerId',
productId: 'productId',
createdAt: 'createdAt'
};
exports.Prisma.CustomerAddressScalarFieldEnum = {
id: 'id',
customerId: 'customerId',
@@ -290,7 +303,10 @@ exports.Prisma.ProductScalarFieldEnum = {
productCode: 'productCode',
showType: 'showType',
isActive: 'isActive',
createdAt: 'createdAt'
createdAt: 'createdAt',
accountingStockWeight: 'accountingStockWeight',
accountingStockCount: 'accountingStockCount',
accountingStockAt: 'accountingStockAt'
};
exports.Prisma.ProductImageScalarFieldEnum = {
@@ -326,6 +342,8 @@ exports.Prisma.OrderScalarFieldEnum = {
flatRialCharge: 'flatRialCharge',
isSuspendedOverCreditLimit: 'isSuspendedOverCreditLimit',
deliveredAt: 'deliveredAt',
salesAgentId: 'salesAgentId',
referrerCustomerId: 'referrerCustomerId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -453,6 +471,51 @@ exports.Prisma.SettingScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.AccountingLinkScalarFieldEnum = {
id: 'id',
provider: 'provider',
entityType: 'entityType',
entityId: 'entityId',
externalCode: 'externalCode',
externalName: 'externalName',
origin: 'origin',
snapshot: 'snapshot',
syncedAt: 'syncedAt',
createdAt: 'createdAt'
};
exports.Prisma.AccountingOutboxScalarFieldEnum = {
id: 'id',
provider: 'provider',
kind: 'kind',
entityId: 'entityId',
status: 'status',
attempts: 'attempts',
nextAttemptAt: 'nextAttemptAt',
lastError: 'lastError',
externalRef: 'externalRef',
progress: 'progress',
lastRequest: 'lastRequest',
lastResponse: 'lastResponse',
sentAt: 'sentAt',
resolvedById: 'resolvedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountingIssueScalarFieldEnum = {
id: 'id',
provider: 'provider',
kind: 'kind',
externalCode: 'externalCode',
entityId: 'entityId',
message: 'message',
data: 'data',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
resolvedAt: 'resolvedAt'
};
exports.Prisma.PromotionScalarFieldEnum = {
id: 'id',
title: 'title',
@@ -567,6 +630,11 @@ exports.Prisma.JsonNullValueInput = {
JsonNull: Prisma.JsonNull
};
exports.Prisma.NullableJsonNullValueInput = {
DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
@@ -603,7 +671,8 @@ exports.VerificationStep = exports.$Enums.VerificationStep = {
UNVERIFIED: 'UNVERIFIED',
STEP_ONE_SUBMITTED: 'STEP_ONE_SUBMITTED',
STEP_TWO_SUBMITTED: 'STEP_TWO_SUBMITTED',
VERIFIED: 'VERIFIED'
VERIFIED: 'VERIFIED',
REJECTED: 'REJECTED'
};
exports.ProductImageRole = exports.$Enums.ProductImageRole = {
@@ -665,6 +734,27 @@ exports.CommentStatus = exports.$Enums.CommentStatus = {
REJECTED: 'REJECTED'
};
exports.AccountingEntityType = exports.$Enums.AccountingEntityType = {
CUSTOMER: 'CUSTOMER',
PRODUCT: 'PRODUCT'
};
exports.AccountingOutboxKind = exports.$Enums.AccountingOutboxKind = {
CUSTOMER_PUSH: 'CUSTOMER_PUSH',
PRODUCT_PUSH: 'PRODUCT_PUSH',
ORDER_DOCUMENT: 'ORDER_DOCUMENT',
ORDER_DOCUMENT_DELETE: 'ORDER_DOCUMENT_DELETE'
};
exports.AccountingOutboxStatus = exports.$Enums.AccountingOutboxStatus = {
PENDING: 'PENDING',
SENDING: 'SENDING',
SENT: 'SENT',
FAILED: 'FAILED',
NEEDS_REVIEW: 'NEEDS_REVIEW',
RESOLVED: 'RESOLVED'
};
exports.PromotionScope = exports.$Enums.PromotionScope = {
PRODUCT: 'PRODUCT',
CATEGORY: 'CATEGORY',
@@ -693,6 +783,7 @@ exports.Prisma.ModelName = {
AdminSession: 'AdminSession',
SalesExpertProfile: 'SalesExpertProfile',
Customer: 'Customer',
CustomerFavorite: 'CustomerFavorite',
CustomerAddress: 'CustomerAddress',
Branch: 'Branch',
Workshop: 'Workshop',
@@ -715,6 +806,9 @@ exports.Prisma.ModelName = {
Permission: 'Permission',
Comment: 'Comment',
Setting: 'Setting',
AccountingLink: 'AccountingLink',
AccountingOutbox: 'AccountingOutbox',
AccountingIssue: 'AccountingIssue',
Promotion: 'Promotion',
PromotionExclusion: 'PromotionExclusion',
BranchAllocation: 'BranchAllocation',