feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -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;
|
||||
@@ -400,6 +400,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:
|
||||
@@ -557,6 +565,14 @@ 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[]
|
||||
@@ -911,6 +927,115 @@ model Setting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting integration (D27) — the shop's accounting software, Tahesab or
|
||||
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
|
||||
// `accounting.config` (never served by the generic `const` routes). Plan:
|
||||
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
//
|
||||
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
|
||||
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
|
||||
// switching provider and back loses nothing.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AccountingEntityType {
|
||||
CUSTOMER
|
||||
PRODUCT
|
||||
}
|
||||
|
||||
/// One app row ↔ one record in the accounting software (customer account or
|
||||
/// کارساخته).
|
||||
model AccountingLink {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
entityType AccountingEntityType
|
||||
entityId String
|
||||
/// The record's code in the accounting software (Tahesab Moshtari_Code /
|
||||
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
|
||||
externalCode String
|
||||
externalName String?
|
||||
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
|
||||
/// row created from the accounting record), PUSHED (accounting record created
|
||||
/// from the app row) or MANUAL (an admin typed the code).
|
||||
origin String
|
||||
/// The record as last read, for display only.
|
||||
snapshot Json?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([provider, entityType, entityId])
|
||||
@@unique([provider, entityType, externalCode])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
enum AccountingOutboxKind {
|
||||
CUSTOMER_PUSH
|
||||
PRODUCT_PUSH
|
||||
ORDER_DOCUMENT
|
||||
ORDER_DOCUMENT_DELETE
|
||||
}
|
||||
|
||||
enum AccountingOutboxStatus {
|
||||
PENDING
|
||||
/// A call was in flight. Found at startup, it means the process died mid-call
|
||||
/// and the outcome is unknown — see AccountingOutboxWorker.
|
||||
SENDING
|
||||
SENT
|
||||
FAILED
|
||||
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
|
||||
/// idempotency key, so a blind retry could post the document twice).
|
||||
NEEDS_REVIEW
|
||||
/// Closed by an admin from the log screen.
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
/// Work to push to the accounting software. Written in the SAME transaction as
|
||||
/// the change that causes it, so an outage of the accounting software never
|
||||
/// loses an event and never blocks the app.
|
||||
model AccountingOutbox {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind AccountingOutboxKind
|
||||
entityId String
|
||||
status AccountingOutboxStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastError String?
|
||||
/// What the accounting software returned as the document/record id.
|
||||
externalRef String?
|
||||
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
|
||||
/// line), so a failure resumes where it stopped.
|
||||
progress Json?
|
||||
/// Last request/response, for the log screen. Never contains the token.
|
||||
lastRequest Json?
|
||||
lastResponse Json?
|
||||
sentAt DateTime?
|
||||
resolvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([kind, entityId])
|
||||
}
|
||||
|
||||
/// Something a pull found that needs a human: an account with no mobile, two
|
||||
/// accounts sharing one mobile, a کارساخته group with no category mapping.
|
||||
model AccountingIssue {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind String
|
||||
externalCode String
|
||||
entityId String?
|
||||
message String
|
||||
data Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
resolvedAt DateTime?
|
||||
|
||||
@@unique([provider, kind, externalCode])
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Promotions / discounts — legacy views/promotion/*, served by
|
||||
// PromotionController (`product/variant/promotion`). The redesigned schema had
|
||||
|
||||
Reference in New Issue
Block a user