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:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
+219
View File
@@ -0,0 +1,219 @@
# طرح اتصال به نرم‌افزار حسابداری (ته‌حساب / زرین)
> وضعیت: **تأیید شد (2026-10-05) — در حال اجرا.** پیشرفت در بخش ۱۰ («وضعیت اجرا»). تصمیم: D27 در `docs/parity/DECISIONS.md`.
> تاریخ: 2026-10-05
## ۰. زمینه
الان اپ هیچ اتصال خارجی به حسابداری ندارد: مانده، گزارش و بدهی سفارش همه از دفتر داخلی (`LedgerEntry`) است (D24). سیستم قدیم (henza) به ته‌حساب وصل بود ولی با توکن هاردکد، TLS خاموش و بدون چک مالکیت (SEC-06/16/17). حالا باید اپ به **یکی از دو** نرم‌افزار حسابداری وصل شود و در پنل ادمین با یک سوییچ انتخاب شود کدام. خروجی: همگام‌سازی مشتری، مانده، کارساخته/محصول، و ثبت سند سفارش — امن، idempotent و قابل پیگیری از ادمین.
## ۱. خلاصه‌ی دو API (از داکیومنت‌ها)
| | ته‌حساب | زرین |
|---|---|---|
| مستند | OpenAPI 3.1 کامل، ۶۶ متد (`tahesab.ir/APiDoc/openapi.json`) | ۸۲ API (`zarrin.ir/wp-content/themes/Hirad/data/api.json`) |
| نصب | Self-hosted روی سیستم/سرور مشتری؛ پورت https 8081 / http 9550 | Self-hosted، `http://host:8000/ZarrinService/accounting` |
| فراخوانی | `POST /<Method>` با بدنه‌ی `{"<Method>": [آرگومان‌ها به ترتیب اندیس]}` | `GET ?kind=<api>&token=…&param=…` |
| احراز هویت | `Authorization: Bearer` + هدر `DBname` | `token` در query string |
| ضد تکرار | `Factor_Code` یکتای ≥۲۰ کاراکتر + `DoNewSanadInquiry-By-Factor-Code` + `DoDeleteSanad` | فقط `codeRahgiri` در `sanadPooli`؛ `foroushSakhte` کلید ضد تکرار **ندارد** |
| سرور تست | دارد (`api-test.tahesab.ir`، توکن عمومی در خود داکیومنت) | ندارد (مالک: فعلاً دسترسی نداریم) |
نگاشت قابلیت‌ها به متدها:
| قابلیت | ته‌حساب | زرین |
|---|---|---|
| لیست/جستجوی مشتری | `DoListMoshtari-ByCode`، `-ByPhone`، `DoListGorooh` | `listhesabha` / `Get100Account`، `getinfoByPhone`، `listGorooheHesabha` |
| ساخت/ویرایش مشتری | `DoNewMoshtari` (کد `-1` = خودکار)، `DoEditMoshtari` | `addAccount` (`fkNoe`=گروه)، `edithesab` |
| مانده | `GetMandeHesabByCode` (لیست کدها، دسته‌ای) → `MandeyeVazni`، `MandeyeMali` | `listMandeHesabha` (همه‌ی حساب‌ها یک‌جا) / `mandeHesabGeramiPooliTedadi` |
| ریز اسناد (گزارش) | `DoListAsnad` (با مانده‌ی ردیف‌به‌ردیف `TahesabVazni/Mali`) | `parvande` (صفحه‌بندی، ردیف «مانده از قبل») |
| کارساخته: لیست/اطلاعات | `DoListNameKarsakhte` (فقط کارهای دارای سند) | `listKarSakhte` (کند: ~۶ ثانیه)، `infosakhte`، `goroohSakhte` |
| کارساخته: موجودی | `GetMojoodiKarSakhte` (با نام کار) | `mojoodiKala` |
| کارساخته: گردش | `DoListAsnad` با فیلتر نوع سند (باید روی سرور تست تأیید شود) | `infogardeshsakhte` |
| کارساخته: ساخت | **متد ندارد** — نام کار با اولین سند ساخته می‌شود | `addFelezsakhteModel` (ویرایش ندارد) |
| سند سفارش (کارساخته، بنکداری) | `DoNewSanadVKHKar` (خروج کارساخته، چندردیفی با `MultiRadif`) | `foroushSakhte` با `bonak=t` (ردیف اول با `idHesab`، بقیه با `idFactor`) |
| موقت/حذف سند | `Sabte_Kol_Or_Movaghat=0`، `DoDeleteSanad` | `movaghat=1`، `hazfRadifSanad` (فقط ردیف‌های API) |
## ۲. تصمیم‌های مالک (این جلسه)
1. سه محور + کارساخته: سینک مشتری، سینک مانده، ثبت سند سفارش، و همگام‌سازی کارساخته↔محصول. **بقیه‌ی چیزها از اپ خودمان** (اسناد دریافت/آبشده/چک در اپ می‌مانند و ارسال نمی‌شوند).
2. مانده‌ی حسابداری **به مشتری نمایش داده می‌شود**؛ **سقف اعتبار از دفتر داخلی اپ** می‌ماند.
3. **گزارش (ریز اسناد) هم از حسابداری** خوانده شود تا با مانده یکی باشد.
4. مشتری جدید اپ **بعد از احراز هویت (VERIFIED)** در حسابداری ساخته می‌شود؛ مشتری‌های موجود با **موبایل** لینک می‌شوند. مشتری‌ای که فقط در حسابداری است **در اپ ساخته شود** — اولویت با موبایل است، نه کد ملی.
5. سند سفارش در **لحظه‌ی ثبت بدهی (تحویل مسئول فروش)** و به‌صورت **موقت** ثبت می‌شود.
6. فعلاً **فقط کارساخته**؛ معاملات طلا/سکه بعداً (لایه طوری ساخته می‌شود که با یک متد اضافه شود).
7. کارساخته‌ی حسابداری = **محصول** اپ. چه در ادمین ثبت شود چه در حسابداری، باید همگام شود.
8. فیلدهای مشترک (عنوان، کد، موجودی…) **مال حسابداری** است؛ عکس، توضیحات، واریانت‌ها، دسته‌بندی و **درصد اجرت مال اپ** (درصد حسابداری فقط پیش‌فرض واریانت جدید).
9. **بدون عکس در اپ نمایش داده نشود** — خودکار، برای همه‌ی محصولات.
10. موجودی حسابداری در ادمین نمایش داده می‌شود و اگر صفر شد محصول در اپ **ناموجود** می‌شود (مگر پیش‌سفارش).
11. محیط تست زرین نداریم → درایور زرین با داده‌های نمونه‌ی داکیومنت تست می‌شود و روشن‌کردنش روی نصب واقعی فقط بعد از یک دوره‌ی آزمایشی با سند موقت.
## ۳. معماری
### ۳.۱ ماژول و درایورها
ماژول جدید `apps/api/src/modules/accounting-sync/` با همان الگوی درایور پیامک (`apps/api/src/modules/sms/drivers/` — یک فایل برای هر درایور + یک رجیستری):
- `providers/accounting-provider.ts` — اینترفیس مشترک: `health()`, `listCustomerGroups()`, `findCustomersByPhone()`, `listCustomers(cursor)`, `createCustomer()`, `updateCustomer()`, `getBalances(codes[])`, `getStatement(code, from, to, page)`, `listKarSakhte()`, `getKarSakhteStock(ref)`, `getKarSakhteMovement(from, to)`, `createKarSakhte()` (اختیاری — `capabilities` اعلام می‌کند)، `postOrderDocument(order)`, `inquireDocument(ref)`, `deleteDocument(ref)`.
- `providers/tahesab.provider.ts` ، `providers/zarrin.provider.ts` ، `providers/index.ts` (رجیستری).
- `accounting-http.ts` — کلاینت HTTP با timeout (`AbortController`، مثل `deliverViaTrez`)، **TLS همیشه روشن** با امکان دادن گواهی CA اختصاصی برای نصب‌های self-signed (نه `withoutVerifying` سیستم قدیم — SEC-17). پاک‌سازی پاسخ خراب ته‌حساب (BOM/کاراکتر کنترلی) مثل `TahesabController::test` قدیم.
- `jalali.ts` — تبدیل تاریخ با `Intl` (`-u-ca-persian`)، بدون وابستگی جدید؛ ته‌حساب سال/ماه/روز شمسی جدا می‌خواهد.
- `accounting-config.service.ts`, `accounting-sync.service.ts` (منطق همگام‌سازی)، `accounting-outbox.worker.ts`، `accounting-admin.controller.ts`.
### ۳.۲ تنظیمات (ادمین، نه env)
یک ردیف `Setting` با کلید `accounting.config`: `provider: 'none' | 'tahesab' | 'zarrin'`، اتصال هر درایور (آدرس، `DBname`، توکن، CA)، نگاشت‌ها، کلید روشن/خاموش هر قابلیت و بازه‌های زمان‌بندی.
- **راز (توکن)** write-only: هیچ API آن را برنمی‌گرداند (فقط `••••` + «تنظیم شده»)، با کلید env `ACCOUNTING_SECRET_KEY` (خوانده‌شده با `envOr` طبق D25) رمز می‌شود.
- **حفره‌ی موجود را می‌بندیم:** `GET/PUT/DELETE const` در [admin-misc.service.ts](apps/api/src/modules/admin-misc/admin-misc.service.ts) همه‌ی ردیف‌های `Setting` را به هر کسی با `config.statics` می‌دهد؛ کلیدهای `accounting.*` از این مسیرها حذف/رد می‌شوند.
- تعویض درایور: صف کارهای درایور قبلی متوقف (نه حذف) می‌شود؛ لینک‌ها per-provider هستند، پس برگشت به درایور قبلی بی‌خطر است. ادمین هنگام تعویض هشدار می‌بیند.
### ۳.۳ تغییرات اسکیما (`apps/api/prisma/schema.prisma` + migration)
- `AccountingLink` — `provider`, `entityType (CUSTOMER|PRODUCT)`, `entityId`, `externalCode`, `externalName`, `snapshot Json`, `syncedAt`؛ یکتا روی `(provider, entityType, entityId)` و `(provider, entityType, externalCode)`.
- `AccountingOutbox` — `provider`, `kind (CUSTOMER_PUSH|ORDER_DOCUMENT|ORDER_DOCUMENT_DELETE|PRODUCT_PUSH)`, `entityId`, `status (PENDING|SENT|FAILED|NEEDS_REVIEW|PAUSED)`, `attempts`, `nextAttemptAt`, `lastError`, `externalRef`, `progress Json` (برای سند چندردیفی زرین)، `request/response` خلاصه **بدون توکن**.
- `Customer`: `accountingGoldBalance`, `accountingRialBalance` (Decimal)، `accountingBalanceAt`.
- `Product`: `accountingStockWeight`, `accountingStockCount` (Decimal?)، `accountingStockAt`.
### ۳.۴ صف خروجی (Outbox) و زمان‌بند
- رویدادها **داخل همان تراکنش** کار اصلی در `AccountingOutbox` نوشته می‌شوند (مثلاً سند سفارش در همان تراکنشی که `postOrderDebt` اجرا می‌شود) → هیچ رویدادی گم نمی‌شود و قطعی حسابداری سفارش را متوقف نمی‌کند.
- Worker درون‌پردازه‌ای با `setInterval` + `pg_try_advisory_lock` (چند نمونه‌ی API هم‌زمان اجرا نکنند) — بدون وابستگی جدید. retry با backoff نمایی؛ بعد از N بار → `FAILED` و نمایش در ادمین.
- **ابهام timeout:** ته‌حساب → قبل از تلاش دوباره `DoNewSanadInquiry-By-Factor-Code`؛ زرین (بدون کلید ضد تکرار) → `NEEDS_REVIEW` به‌جای ارسال کور دوباره.
- کارهای pull دوره‌ای (مانده، کارساخته، مشتری) همین worker با بازه‌های قابل تنظیم.
## ۴. قابلیت‌ها
### F1 — مشتری
- **Pull (حسابداری → اپ):** حساب‌های گروه‌های انتخاب‌شده در ادمین (مثلاً «مشتري» GID 13 در ته‌حساب، «مشتری (خریدار)» در زرین) خوانده می‌شوند؛ موبایل نرمال می‌شود (`faToEn` + تبدیل `912…`→`0912…`).
- موبایل در اپ هست → لینک.
- نیست → **مشتری اپ ساخته می‌شود**: `nationalCode` = کد ملی حسابداری اگر معتبر و آزاد بود، وگرنه **خود موبایل** (همان fallback موجود در `customer-auth.service.ts:258` و `admin-customers.service.ts`)؛ `passwordHash: ''` (ورود با OTP)؛ `UNVERIFIED`، سقف اعتبار ۰ (بقیه مال اپ است).
- حساب بدون موبایل، یا چند حساب با یک موبایل → لیست «مغایرت» در ادمین، بدون اقدام خودکار.
- **Push (اپ → حسابداری):** وقتی ادمین مشتری را VERIFIED می‌کند و لینک ندارد → اول جستجو با موبایل (لینک اگر پیدا شد)، وگرنه ساخت (`DoNewMoshtari` / `addAccount`) در گروه انتخاب‌شده. ویرایش نام/تلفن/آدرس مشتری لینک‌شده → `DoEditMoshtari` / `edithesab`. همه از طریق Outbox.
- ثبت‌نام با موبایلی که قبلاً از حسابداری وارد شده → همان ردیف (موبایل یکتاست)، بدون حساب تکراری.
### F2 — مانده
- دسته‌ای و دوره‌ای (پیش‌فرض ۱۰ دقیقه): ته‌حساب `GetMandeHesabByCode` با تکه‌های ۱۰۰تایی کدها؛ زرین `listMandeHesabha` یک‌جا. ذخیره در `Customer.accountingGold/RialBalance`.
- تازه‌سازی تکی وقتی مشتری داشبورد را باز می‌کند و مانده قدیمی‌تر از X ثانیه است (timeout ۳ ثانیه، در خطا همان مقدار ذخیره‌شده).
- **قرارداد علامت:** ته‌حساب منفی = بدهکار (کد قدیم `* -1` می‌کرد)؛ به قرارداد اپ (مثبت = بدهکار) تبدیل می‌شود. روی سرور تست تأیید می‌شود.
- نمایش: `balances()` در [customer-report.service.ts](apps/api/src/modules/customer-report/customer-report.service.ts) وقتی اتصال روشن است مانده‌ی حسابداری + زمان آخرین سینک را برمی‌گرداند (همان شکل `{arz, mandeh}` → اپ‌ها بدون تغییر). پنل ادمین هر دو مانده را کنار هم نشان می‌دهد.
- **سقف اعتبار دست نمی‌خورد:** چک در [orders.service.ts:88-92](apps/api/src/modules/orders/orders.service.ts#L88-L92) همچنان با `goldDebtGrams` داخلی.
### F3 — سند سفارش (کارساخته)
- ماشه: شاخه‌ی `DELIVERED_TO_AGENT && !order.deliveredAt` در `updateStatus` ([orders.service.ts:154-157](apps/api/src/modules/orders/orders.service.ts#L154-L157)) — همان تراکنش `postOrderDebt` — یک ردیف `ORDER_DOCUMENT` در Outbox. پیش‌سفارش تبدیل‌شده هم چون Order می‌شود از همین مسیر می‌رود.
- همه‌ی اسناد **موقت** ثبت می‌شوند تا حسابدار نهایی کند.
- **ته‌حساب:** هر قلم یک ردیف `DoNewSanadVKHKar` (خروج کارساخته، `IsVoroodOrKhorooj=1`)، `MultiRadif=1`، `Radif_Number` ۱..n، `Factor_Code` قطعی = `GB-ORD-<order.id>` (≥۲۰ کاراکتر → ضد تکرار)، `Vazn`=وزن راش×تعداد، `Ayar`=750 (قابل تنظیم)، `Count`، `Name_Kar`=نام کار لینک‌شده، درصد اجرت مؤثر (اجرت − تخفیف)، `PoolSang`=هزینه‌ی ریالی ثابت.
- **زرین:** `foroushSakhte` با `bonak=t`, `pd=-1`, `movaghat=1`؛ قلم اول با `idHesab` → `idFactor`، اقلام بعد با `idFactor`؛ `idSakhte`=کارساخته‌ی لینک‌شده، `vazn`، `darsad`=اجرت مؤثر، `vaznSang/mablaghSang`. پیشرفت هر قلم در `progress` ذخیره می‌شود تا شکست وسط کار از همان‌جا ادامه یابد.
- **پذیرش:** بعد از ثبت، تغییر مانده‌ی وزنی حسابداری باید دقیقاً برابر `order.totalGoldWeightGrams` باشد (این‌که اجرت در کدام فیلد برود — `Darsad_Talayee` یا وزن محاسبه‌شده — با همین معیار روی سرور تست تعیین می‌شود).
- لغو سفارش بعد از ارسال سند → `ORDER_DOCUMENT_DELETE` (`DoDeleteSanad` / `hazfRadifSanad kol=1`) فقط اگر سند هنوز موقت است؛ وگرنه `NEEDS_REVIEW`.
- محصولِ بدون لینک موقع سند: چون طبق F4 هر محصول لینک دارد، این حالت استثناست → `NEEDS_REVIEW` با پیام روشن.
### F4 — کارساخته ↔ محصول
- **Pull (حسابداری → اپ)** دوره‌ای (پیش‌فرض ۳۰ دقیقه چون `listKarSakhte` کند است): کارساخته‌ی جدید → `Product` جدید (عنوان = نام، `productCode` = کد حسابداری، دسته از نگاشت «گروه ساخته → دسته‌بندی» در ادمین) بدون عکس و بدون واریانت → در اپ دیده نمی‌شود تا ادمین عکس و واریانت (وزن/تعداد) بگذارد. درصد فروش حسابداری فقط پیش‌فرض واریانت جدید.
- محصول لینک‌شده: **عنوان و کد** از حسابداری به‌روز می‌شوند و در فرم ادمین قفل‌اند؛ عکس، توضیحات، واریانت، دسته و اجرت آزاد.
- **Push (ادمین → حسابداری):**
- زرین: محصول جدید → `addFelezsakhteModel` (`noeKar` و `gorooheKar` از نگاشت ادمین) → لینک `idKar`. چون زرین ویرایش ندارد، عنوان بعد از لینک فقط از حسابداری عوض می‌شود.
- ته‌حساب: متد ساخت ندارد → `Name_Kar` = عنوان محصول؛ کار با اولین سند در ته‌حساب پدید می‌آید و از آن به بعد در `DoListNameKarsakhte` دیده و لینک می‌شود. (محدودیت API، نه انتخاب ما.)
- **موجودی:** `mojoodiKala` / `GetMojoodiKarSakhte` → `Product.accountingStock*`؛ ستون در لیست محصولات ادمین. اگر ≤ ۰ → محصول در اپ ناموجود (واریانت‌های پیش‌سفارش مستثنا) — هم در کاتالوگ هم در `createOrder` اعمال می‌شود. تعداد واریانت‌ها همچنان در ادمین.
- **گردش کارساخته:** صفحه‌ی گزارش در ادمین (بازه‌ی تاریخ) — زرین `infogardeshsakhte`؛ ته‌حساب از `DoListAsnad` اگر روی سرور تست فیلتر نوع سند جواب داد، وگرنه در این صفحه «پشتیبانی نمی‌شود».
### F5 — قانون عکس
- [catalog.service.ts](apps/api/src/modules/catalog/catalog.service.ts) (تنها مسیر لیست محصول برای مشتری) فیلتر `images: { some: {} }` می‌گیرد؛ `createOrder` واریانتِ محصول بی‌عکس را رد می‌کند.
- قبل از انتشار، تعداد محصولات فعلی بی‌عکس که پنهان می‌شوند گزارش می‌شود.
### F6 — گزارش از حسابداری
- وقتی اتصال روشن است، `summary`/`detail` در `customer-report.service.ts` از درایور خوانده می‌شود و به همان شکل `henza_pwa/src/type/accounting.ts` نگاشت می‌شود (اپ‌ها بدون تغییر):
- ته‌حساب `DoListAsnad` (`Count_Last=-1` برای مانده‌ی ردیف‌به‌ردیف) → `tahesab_T`=`TahesabVazni`، `tahesab_P`=`TahesabMali`، `factor_Code`، `sh_factor`، `tarikh`…
- زرین `parvande` → وزن/پول دریافت و پرداخت؛ مانده‌ی جاری از ردیف «مانده از قبل» جمع زده می‌شود.
- **SEC-16 حفظ می‌شود:** جزئیات سند فقط اگر کد حساب سند = کد لینک‌شده‌ی همین مشتری (از توکن).
- ستون‌هایی که REP-01 خالی گذاشته بود (نام کارساخته، جنس فلز…) حالا منبع دارند.
- اتصال خاموش یا خطا → همان گزارش دفتر داخلی فعلی (با برچسب منبع).
## ۵. پنل ادمین
- صفحه‌ی جدید `/config/manage/accounting` «اتصال حسابداری»، مجوز جدید `config.accounting` (در `apps/api/src/modules/auth/acl.constants.ts`، `apps/admin-panel/src/views/user/permissionConst.ts`، `layouts/navMenuItems.ts`، `router/admin.ts`)، با الگوی Ant Design صفحه‌ی `views/manage/wallet.vue`:
1. **اتصال:** سوییچ «بدون اتصال / ته‌حساب / زرین»، فیلدهای همان درایور، دکمه‌ی «تست اتصال» (`CheckHealth` / `version`).
2. **نگاشت:** گروه‌های مشتری برای ورود، گروه مشتری جدید، گروه ساخته ↔ دسته‌بندی، نوع کارساخته‌ی جدید (زرین)، عیار پیش‌فرض.
3. **قابلیت‌ها و زمان‌بندی:** روشن/خاموش هر قابلیت و بازه‌ها + دکمه‌های «سینک الان».
4. **لاگ:** جدول Outbox با فیلتر وضعیت، تلاش دوباره، «حل‌شده»، مشاهده‌ی درخواست/پاسخ (بدون توکن).
5. **مغایرت‌ها:** موبایل تکراری/خالی، کارساخته‌ی بی‌دسته، سند `NEEDS_REVIEW`.
- صفحه‌ی مشتری: کد حسابداری + هر دو مانده + «لینک دستی با کد».
- لیست/فرم محصول: کد حسابداری، موجودی حسابداری، قفل عنوان/کد، نشان «بدون عکس — در اپ پنهان».
## ۶. فازها
| فاز | محتوا |
|---|---|
| P0 | مستندات (این طرح در `docs/`، D27، به‌روزرسانی REP-01/SEC-17)، migration، تنظیمات + راز + بستن حفره‌ی `const`، اینترفیس درایور، کلاینت HTTP، Outbox/worker، تب «اتصال» + تست اتصال |
| P1 | F1 مشتری |
| P2 | F2 مانده + F6 گزارش |
| P3 | F4 کارساخته/محصول + F5 قانون عکس |
| P4 | F3 سند سفارش |
هر دو درایور در هر فاز ساخته می‌شوند؛ ته‌حساب روی سرور تست عمومی تأیید می‌شود، زرین با fixtureهای داکیومنت و برای نصب واقعی با دوره‌ی آزمایشی سند موقت (تصمیم ۱۱).
## ۷. فایل‌های اصلی
- جدید: `apps/api/src/modules/accounting-sync/**`، `apps/admin-panel/src/views/manage/accounting.vue`، migration جدید Prisma.
- تغییر: `apps/api/prisma/schema.prisma`، `apps/api/src/app.module.ts`، `modules/orders/orders.service.ts` (Outbox + قفل ناموجود)، `modules/admin-customers/admin-customers.service.ts` (هوک VERIFIED/ویرایش، لینک دستی)، `modules/customer-report/customer-report.service.ts`، `modules/catalog/catalog.service.ts`، `modules/admin-products/admin-products.service.ts` (push + قفل فیلد)، `modules/admin-misc/admin-misc.service.ts` (حفاظت `accounting.*`)، `modules/auth/acl.constants.ts`، فایل‌های router/nav/permission پنل ادمین، صفحه‌های مشتری و محصول ادمین.
- مستندات/استقرار: `docs/parity/DECISIONS.md`، `ISSUES.md`، `DELIVERY.md`، `.env.example`، `docker-compose.yml` (`ACCOUNTING_SECRET_KEY`).
## ۸. آزمون
- **واحد (Jest، کنار `sms.service.spec.ts`):** هر درایور با `fetch` ماک‌شده و پاسخ‌های نمونه‌ی خود داکیومنت‌ها: ترتیب آرگومان‌های ته‌حساب، هدرها، تاریخ شمسی، علامت مانده، `Factor_Code`، ادامه‌ی سند چندردیفی زرین، رفتار timeout/500/JSON خراب.
- **Outbox:** نوشتن در همان تراکنش، retry/backoff، inquiry پیش از تکرار (ته‌حساب)، `NEEDS_REVIEW` (زرین)، توقف هنگام تعویض درایور، قفل advisory.
- **یکپارچه با سرور تست ته‌حساب** (اسکریپت در `apps/api/scripts/`، کنار `auth-smoke.sh`): ساخت مشتری → خواندن مانده → سند موقت `VKHKar` → inquiry → بررسی تغییر مانده = وزن سفارش → حذف سند.
- **e2e محلی:** سفارش آزمایشی تا «تحویل مسئول فروش» → ردیف Outbox → `SENT`؛ سقف اعتبار همچنان با دفتر داخلی؛ محصول بی‌عکس در `catalog/products` نیست؛ `GET const` کلید `accounting.config` را برنمی‌گرداند و توکن در هیچ پاسخ/لاگی نیست.
- `pnpm test`، `pnpm build`، lint، و یک دور دستی در پنل ادمین (اتصال، تست، سینک الان، لاگ).
## ۹. مواردی که روی سرور تست بسته می‌شوند (سؤال از شما نیست)
- علامت و واحد مانده‌ی وزنی (گرم ۷۵۰؟) در هر دو.
- فیلد درست اجرت در `VKHKar` (`Darsad_Talayee` یا وزن محاسبه‌شده) با معیار پذیرش F3.
- قالب تاریخ هر API زرین (بعضی میلادی، بعضی شمسی‌اند).
- شناسه‌های `fkNoe`/`noeKar` زرین روی نصب واقعی.
## ۱۰. وضعیت اجرا (2026-10-05)
| فاز | وضعیت |
|---|---|
| P0 | ✅ اسکیما + migration، تنظیمات با توکن رمزشده و write-only، بستن مسیرهای `const`، اینترفیس درایور، کلاینت HTTP، Outbox + worker با lease، صفحه‌ی «اتصال حسابداری» |
| P1 | ✅ مشتری: ورود/لینک با موبایل، ساخت در حسابداری پس از VERIFIED، ویرایش، لیست مغایرت |
| P2 | ✅ مانده (دوره‌ای + تازه‌سازی ۳ ثانیه‌ای در `/customer/me`) و گزارش از حسابداری با بازگشت به دفتر داخلی |
| P3 | ✅ کارساخته ↔ محصول، قفل عنوان/کد، ستون موجودی، قفل ناموجود، قانون عکس |
| P4 | ✅ سند موقت در «تحویل مسئول فروش»، حذف با لغو (فقط سند موقت) |
**آزمون:** ۴۳ تست واحد درایورها/ابزارها (کل API: ۱۸۰ تست سبز)، و `apps/api/scripts/test-accounting-tahesab.ts` روی **سرور تست واقعی ته‌حساب** و یک دیتابیس دورریختنی: ۲۹/۲۹ — از جمله لینک دوباره‌ی همان حساب با موبایل در اجرای دوم، و سندی که دقیقاً به اندازه‌ی وزن سفارش جابه‌جا می‌کند. سند آزمایشی در پایان حذف می‌شود.
### نتیجه‌ی موارد بخش ۹ (اندازه‌گیری روی سرور تست ته‌حساب)
- **علامت مانده:** «خروج» مانده‌ی وزنی را کم می‌کند → منفی = بدهکار. در درایور برعکس می‌شود.
- **اجرت:** اثر سند روی مانده = وزن × عیار ÷ ۷۵۰؛ `Darsad_Talayee` و `Ojrat` فقط ذخیره می‌شوند و اثری ندارند. پس اجرت به‌صورت **افزایش عیار** فرستاده می‌شود (۷۵۰ با ۱۰٪ → ۸۲۵) و وزن فیزیکی دست نمی‌خورد.
- **سنگ:** `PoolSang` روی مانده‌ی ریالی اثر ندارد؛ هزینه‌ی ثابت ریالی در `MablaghKol` می‌رود.
- **سند موقت** تا نهایی‌شدن روی مانده اثر ندارد (ACC-02).
- **ضد تکرار:** `Factor_Code` کوچک‌حرف ذخیره می‌شود؛ ارسال دوباره‌ی همان ردیف خطای duplicate می‌دهد → تلاش دوباره امن است. استعلام سند چندردیفی `Duplicate` برمی‌گرداند (= پیدا شد).
- **زرین:** هنوز روی نصب واقعی آزموده نشده (ACC-01).
### یافته‌های خارج از دامنه (در ISSUES)
- **ACC-03 (High):** ثبت دوباره‌ی بدهی سفارش اگر دو بار به «تحویل مسئول فروش» برود — ناحیه‌ی محافظت‌شده، اصلاح نشد.
- **ACC-04:** `runMigrations` روی دیتابیس کاملاً خالی به‌خاطر ترتیب نام `0002_admin_schema` می‌شکند.
## ۱۱. همگام‌سازی لحظه‌ای (حکم مالک، ۱۴۰۵/۰۷/۱۳ — جایگزین «سینک دوره‌ای»)
مالک: «این باید ریل‌تایم باشد، نه با تأخیر یا سینک دستی». هیچ‌کدام از دو API وب‌هوک ندارند، پس تأخیر با سه لایه به چند ثانیه می‌رسد:
| مسیر | قبل | حالا |
|---|---|---|
| اپ ← حسابداری (سند سفارش، مشتری تأییدشده/ویرایش‌شده) | منتظر tick ۳۰ ثانیه‌ای | بعد از commit همان تراکنش (≈۰٫۷ ثانیه) صف ارسال را بیدار می‌کند؛ حلقه‌ی سریع هر ۲ ثانیه هم صف را می‌خواند |
| حسابداری ← اپ (مانده، موجودی کارساخته) | ۱۰ و ۳۰ دقیقه | **فید تغییرات** هر ۵ ثانیه (ثابت در کد؛ تنظیم ادمینش به خواست مالک حذف شد)؛ فقط حساب‌ها/کارساخته‌های تغییرکرده دوباره خوانده می‌شوند |
| مانده‌ای که مشتری می‌بیند | تازه‌سازی اگر قدیمی‌تر از ۶۰ ثانیه | اگر قدیمی‌تر از ۵ ثانیه، همان لحظه از حسابداری (مهلت ۳ ثانیه؛ در خطا آخرین مقدار) |
| موجودی هنگام **ثبت سفارش** | مقدار ذخیره‌شده | مستقیم از حسابداری (مهلت ۲٫۵ ثانیه)؛ **اگر پاسخ ندهد، سفارش با آخرین موجودی معلوم ثبت می‌شود** و تغییر بعدی اصلاحش می‌کند (حکم مالک) |
**فید تغییرات** (`getChanges` در هر درایور؛ مکان‌نما در حافظه، اولین poll فقط مبدأ را می‌گیرد):
- **ته‌حساب:** `DoListUpdatedAsnad(200)` (≈۴۰۰ms). سند «جدید» = `ID` بزرگ‌تر از مکان‌نما یا `ZamanEdit` دیرتر؛ از هر سند `MCode` (حساب) و برای اسناد کارساخته `No_Kar` (نام کار). روی سرور تست واقعی ثابت شد: سند ثبت‌شد و در poll بعدی (۰٫۷ ثانیه) حساب و کارساخته‌اش گزارش شد.
- **زرین:** `parLastTenRadif` بدون حساب (تازه‌ترین ردیف‌های روزنامه، `idRadif` صعودی) — **هنوز روی نصب واقعی دیده نشده (ACC-01)**؛ نام کارساخته‌ی تغییرکرده را نمی‌دهد، پس موجودی در زرین فقط با بازه‌ی کارساخته و خواندن لحظه‌ی سفارش تازه می‌شود.
- **چیزی که فید نمی‌بیند** (حذف سند/حساب، مشتری جدید بی‌سند، ویرایش مشخصات مشتری): حلقه‌ی ۳۰ ثانیه‌ای قبلی با بازه‌های دقیقه‌ای به‌عنوان **شبکه‌ی اطمینان** می‌ماند. اگر پنجره‌ی فید سرریز شود (همه‌ی ردیف‌ها جدید) یا حسابی/کاری لینک‌نشده دیده شود، همان لحظه کار کامل مربوط (مانده/کارساخته/مشتری، مشتری حداکثر دقیقه‌ای یک بار) اجرا می‌شود.
- حلقه‌ی سریع جدا از حلقه‌ی کند است؛ import طولانی مشتریان آن را نگه نمی‌دارد.
محدودیت: ته‌حساب سند **موقت** را روی مانده اعمال نمی‌کند (ACC-02)؛ لحظه‌ای بودن یعنی «تغییر ثبت‌شده در حسابداری چند ثانیه بعد در اپ است»، نه اینکه سند موقت مانده را عوض کند.
## ۱۲. دکمه‌های سینک، مانده‌ی پروفایل، قانون زمان ثبت سند (حکم مالک، ۱۴۰۵/۰۷/۱۳)
حکم‌ها: دکمه‌ها و مانده‌ی پروفایل **مستقل از کلیدهای قابلیت** کار می‌کنند (فقط اتصال برقرار باشد)؛ قانون سند در همان صفحه‌ی «اتصال حسابداری».
| جا | رفتار |
|---|---|
| «لیست مشتریان عمده» | دکمه‌ی «سینک با …» (فقط وقتی اتصال برقرار است): `POST admin/customers-omde/accounting-sync` (مجوز همان لیست) — ورود/لینک مشتریان (اگر گروه انتخاب شده باشد) + مانده‌ی همه؛ منتظر می‌ماند و نتیجه‌ی هر بخش را نشان می‌دهد |
| «لیست محصولات» | دکمه‌ی مشابه: `POST admin/products/accounting-sync` (مجوز `product.list`) — کارساخته‌ها و موجودی |
| پروفایل اپ (هر دو اپ) | هر بار باز شدن صفحه یک درخواست جدا `GET customer/me/accounting-balance` مانده‌ی **همان** مشتری را همان لحظه از حسابداری می‌خواند و در بخش «مانده‌ی حساب» (طلا و ریال، بدهکار/بستانکار) نشان می‌دهد؛ اگر حسابداری پاسخ ندهد «آخرین مانده‌ی ثبت‌شده» |
| ثبت سند سفارش | قانون `documents.triggerStatus` در تب «قابلیت‌ها و زمان‌بندی»، کنار کلید ثبت سند: سند **موقت (پیش‌فاکتور)** وقتی سفارش به این وضعیت **یا بعد از آن** برسد ثبت می‌شود (ثبت سفارش، تأیید مدیر، تأمین، آماده‌سازی، تحویل مسئول فروش [پیش‌فرض = رفتار قبلی]، تحویل مشتری). ارسال بلافاصله پس از commit |
جزئیات قانون سند: `SUSPENDED` هیچ‌وقت ماشه نیست؛ برگشت وضعیت به زیر قانون (برداشتن وضعیت در ادمین) یا لغو یا **حذف سفارش از ادمین** سند موقت را پس می‌گیرد (حذف فقط اگر هنوز موقت است). در ته‌حساب رسیدن دوباره به قانون سند تازه می‌فرستد (ترتیب صف: اول حذف)؛ در زرین هرگز دو بار (کلید ضد تکرار ندارد). کار حذف سند دیگر به ردیف سفارش نیاز ندارد (کد حساب از درخواست خود سند خوانده می‌شود). ثبت بدهی دفتر داخلی (`postOrderDebt`) دست نخورد.
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# طرح افزودن درگاه پیامکی جدید (Relay / `createsms`)
> وضعیت: **اجرا شد** (2026-10-05) — تصمیم ثبت‌شده: D26.
> تاریخ: 2026-10-05
## ۱. کد ورودی چه می‌کند
کد PHP ارسالی (Laravel) یک پیامک را به یک سرویس واسط (relay) می‌فرستد:
| مورد | مقدار |
|---|---|
| متد / آدرس | `POST http://185.231.112.101/api/createsms` (HTTP ساده، بدون TLS) |
| بدنه | JSON |
| هدر احراز هویت | `secret_key: <کلید>` |
| timeout | ۱۵ ثانیه |
| فیلد `phone` | شماره گیرنده |
| فیلد `text` | متن پیام، `trim()` شده |
| فیلد `host` | `Controller::Config('montek')` و اگر خالی بود IP خود سرور (`gethostbyname(gethostname())`) — یعنی شناسه‌ی «این مشتری/سرور» نزد سرویس واسط |
| فیلد `platform` | اگر `$sendFromKave` درست باشد `"kave"` (کاوه‌نگار)، وگرنه `null` (خط پیش‌فرض سرویس واسط) |
| `CURLOPT_IPRESOLVE = V4` | فقط اجبار IPv4؛ چون آدرس خودش IP است در عمل اثری ندارد |
این کد در `henza_server` وجود ندارد (جستجو شد)، پس از سیستم دیگری آمده و مرجع parity ندارد.
## ۲. کجا قرار می‌گیرد
ماژول پیامک همین الان در [apps/api/src/modules/sms/sms.service.ts](../apps/api/src/modules/sms/sms.service.ts) هست و همه‌ی مصرف‌کننده‌ها (OTP ورود، پیامک خوش‌آمد، اعلان تکمیل پروفایل، …) فقط `SmsService.send()` را صدا می‌زنند. پس:
- **هیچ مصرف‌کننده‌ای تغییر نمی‌کند.** فقط یک «درایور» دوم کنار Trez اضافه می‌شود و با `SMS_PROVIDER=relay` انتخاب می‌شود.
- قوانین فعلی حفظ می‌شود: `send()` هرگز throw نمی‌کند (D20)، هر پیام در `SmsMessage` ثبت می‌شود، متن OTP ذخیره نمی‌شود (SEC-11)، نبود تنظیمات فقط هشدار می‌دهد و سرور بالا می‌آید.
## ۳. تغییرات پیشنهادی
### ۳.۱ کد — `sms.service.ts`
1. `smsConfig()` متغیرهای جدید را هم می‌خواند (جدول ۳.۲).
2. `smsConfigured()` برای `relay`: کافی است `SMS_RELAY_SECRET` و `SMS_RELAY_HOST` پر باشند.
3. `assertSmsConfigured()` مقدار `relay` را می‌شناسد و متغیرهای خالی را با نام گزارش می‌کند (بدون throw).
4. متد جدید `deliverViaRelay(mobile, text, platform)`:
- `fetch` با `Content-Type: application/json` و هدر `secret_key`، بدنه‌ی `{ phone, host, text, platform }`، timeout ۱۵ ثانیه با `AbortController` — هم‌شکل `deliverViaTrez`.
- موفقیت = پاسخ HTTP 2xx (تا وقتی قالب پاسخ سرویس واسط مشخص شود — سؤال ۴).
5. `SmsSendOptions` یک گزینه‌ی اختیاری `platform?: 'kave'` می‌گیرد؛ درایور Trez آن را نادیده می‌گیرد.
6. اجبار IPv4 پیاده نمی‌شود چون آدرس IP است؛ اگر بعداً دامنه شد، با `dns.lookup` با `family: 4` اضافه می‌شود.
### ۳.۲ متغیرهای محیطی جدید
| متغیر | اجباری | توضیح |
|---|---|---|
| `SMS_PROVIDER` | — | مقدار جدید `relay` |
| `SMS_RELAY_URL` | خیر | پیش‌فرض `http://185.231.112.101/api/createsms` |
| `SMS_RELAY_SECRET` | **بله** | مقدار هدر `secret_key` — **فقط در env، هرگز در کد یا git** |
| `SMS_RELAY_HOST` | **بله** | مقدار فیلد `host` (معادل `Config('montek')`) |
| `SMS_RELAY_KAVE_FOR` | خیر | کدام پیام‌ها از کاوه‌نگار بروند — بسته به پاسخ سؤال ۲ |
### ۳.۳ مستندات و استقرار
- `.env.example` (ریشه)، `apps/api/.env.example`، `docker-compose.yml`، `DELIVERY.md` (بخش SMS و گام ۴): افزودن متغیرهای بالا.
- `docs/parity/DECISIONS.md`: ثبت یک تصمیم جدید (D26) برای درایور relay.
### ۳.۴ تست — `sms.service.spec.ts`
- تنظیمات: پیش‌فرض URL، `smsConfigured` با/بدون secret و host، هشدار با نام متغیرهای خالی.
- ارسال با `fetch` ماک‌شده: آدرس، هدر `secret_key`، بدنه‌ی JSON، `trim` متن، `platform` درست؛ و این‌که خطای شبکه/timeout/HTTP 500 به `{ ok: false }` تبدیل شود و throw نشود.
## ۴. هشدار امنیتی ⚠
1. **کلید `secret_key` لو رفته فرض شود.** در متن پیام چت آمده و احتمالاً در git پروژه‌ی PHP هم هست (مشابه SEC-12). باید از صاحب سرویس واسط کلید جدید گرفت و فقط در env سرور گذاشت. این کلید در کد این مخزن نوشته نمی‌شود.
2. **ارتباط HTTP ساده است**؛ کلید و متن پیام (از جمله کد OTP) رمزنشده از اینترنت عبور می‌کنند. اگر سرویس واسط HTTPS دارد از آن استفاده شود.
## ۵. سؤال‌ها (با پیشنهاد)
1. **جایگزین یا کنار Trez؟** پیشنهاد: کنار Trez بماند و با `SMS_PROVIDER=relay` انتخاب شود؛ پیش‌فرض همان `trez` بماند تا رفتار فعلی عوض نشود. یا پیش‌فرض `relay` شود؟
2. **`sendFromKave` کی درست است؟** در کد اصلی این متغیر از کجا مقدار می‌گیرد؟ پیشنهاد: فقط OTP ورود از کاوه‌نگار برود (معمولاً برای سرعت/تحویل بهتر) و بقیه از خط پیش‌فرض — قابل تنظیم با env.
3. **مقدار `host` (`montek`) چیست؟** پیشنهاد: اجباری در env. fallback به IP خود سرور پیاده نمی‌شود، چون داخل Docker، IP کانتینر (مثلاً `172.x`) برمی‌گردد که برای سرویس واسط بی‌معناست.
4. **پاسخ سرویس واسط چه شکلی است؟** اگر در خطا هم 200 برمی‌گرداند (مثلاً `{"status": false}`)، نمونه‌ی پاسخ موفق و ناموفق لازم است تا `ok` درست تشخیص داده شود. پیشنهاد فعلی: فقط وضعیت HTTP.
5. **HTTPS دارد؟** (بند ۴.۲)
## ۷. پاسخ‌های مالک (2026-10-05)
| سؤال | پاسخ | اثر روی پیاده‌سازی |
|---|---|---|
| کلید | لو نرفته؛ تأیید دومرحله‌ای جای دیگری هست؛ داخل پروژه قرار بگیرد | کلید به‌عنوان مقدار پیش‌فرض در کد می‌آید و `SMS_RELAY_SECRET` فقط آن را بازنویسی می‌کند. بند ۴.۱ لغو شد. |
| ۱. جایگزین Trez؟ | پیش‌فرض `relay` شود؛ Trez حذف نشود؛ ممکن است بعداً سرویس‌های بیشتری بیاید | سرویس به ساختار «درایور» بازنویسی می‌شود: یک اینترفیس `SmsDriver` و هر سرویس یک فایل جدا (`drivers/relay.driver.ts`، `drivers/trez.driver.ts`). برای اضافه کردن سرویس بعدی فقط یک فایل و یک خط ثبت لازم است. پیش‌فرض `SMS_PROVIDER` برابر `relay` می‌شود. |
| ۳. `host` | IP سرور درست است | `SMS_RELAY_HOST` اختیاری است. اگر خالی باشد، مثل PHP اولین IPv4 غیرداخلی سرور فرستاده می‌شود. |
| ۴. پاسخ | با تشخیص خودم | `ok` فقط وقتی است که HTTP 2xx باشد و بدنه‌ی JSON هم نشانه‌ی شکست صریح نداشته باشد (`status: false`، `success: false`، `error`). در غیر این صورت `ok: false` + لاگ؛ هیچ‌وقت throw نمی‌شود. |
| ۵. HTTPS | دارد | بررسی شد: روی `https://185.231.112.101` گواهی `*.montpar.ir` نصب است که **از 2026-08-22 منقضی شده** و روی IP هم معتبر نیست ← سؤال ب |
### سؤال‌های باز
- **الف) `sendFromKave`:** کدام پیام‌ها از کاوه‌نگار بروند؟ پیشنهاد: فقط OTP ورود.
- **ب) آدرس HTTPS:** نام دامنه‌ی سرویس (زیرمجموعه‌ای از `montpar.ir`) چیست؟ گواهی هم باید تمدید شود. تا آن موقع پیش‌فرض همان `http://185.231.112.101/api/createsms` می‌ماند و با `SMS_RELAY_URL` عوض می‌شود.
## ۶. وضعیت اجرا
- [x] تأیید طرح و پاسخ سؤال‌ها (الف: همه‌ی پیام‌ها از کاوه‌نگار؛ ب: همان IP و HTTP درست است)
- [x] کد + تست (`apps/api/src/modules/sms/drivers/`، ۱۳۵ تست سبز)
- [x] env / compose / DELIVERY / DECISIONS (D26) / ISSUES (REL-02)
+181
View File
@@ -0,0 +1,181 @@
-- ============================================================================
-- پیش‌بررسی دوم — سه سؤال باز
-- ============================================================================
--
-- ⚠️ مثل فایل اول: فقط SELECT COUNT / SUM. صفر INSERT/UPDATE/DELETE.
-- هیچ ستون متنی در خروجی نیست.
--
-- اجرا:
-- mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck-2.sql > out2.txt
--
-- مرجع: docs/DATA-MIGRATION-MAP.md §۱۰.۴ · §۱۰.۶
-- ============================================================================
-- ---------------------------------------------------------------------------
-- 🔴 سؤال ۱ (مسدودکننده) — مبلغ سند مالی در کدام ستون است؟
--
-- در خروجی اول، SUM(cashes.remain) و SUM(cashes.rial_remain) هر دو NULL
-- برگشتند — یعنی در هر ۶۴۸ ردیف NULL هستند. پس مبلغ جای دیگری است.
-- بدون جواب این، LedgerEntry.amount خالی پر می‌شود و کل دفتر مالی
-- بی‌مبلغ منتقل می‌شود.
-- ---------------------------------------------------------------------------
SELECT '=== 1. WHERE IS THE LEDGER AMOUNT? ===' AS section;
-- هر ستون عددی cashes: چند ردیف مقدار غیرNULL و غیرصفر دارند؟
SELECT 'input' AS col, COUNT(input) AS not_null, SUM(input <> 0) AS nonzero FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'output', COUNT(output), SUM(output <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'remain', COUNT(remain), SUM(remain <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_input', COUNT(rial_input), SUM(rial_input <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_output', COUNT(rial_output), SUM(rial_output <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_remain', COUNT(rial_remain), SUM(rial_remain <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'value', COUNT(value), SUM(value <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'original_value', COUNT(original_value), SUM(original_value <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'balance', COUNT(balance), SUM(balance <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'price', COUNT(price), SUM(price <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'price_per_gram', COUNT(price_per_gram), SUM(price_per_gram <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'discount', COUNT(discount), SUM(discount <> 0) FROM cashes WHERE deleted_at IS NULL;
-- جمع کل هر ستونی که مقدار دارد → مبنای تطبیق برای ابزار
SELECT
CAST(SUM(input) AS CHAR) AS sum_input,
CAST(SUM(output) AS CHAR) AS sum_output,
CAST(SUM(value) AS CHAR) AS sum_value,
CAST(SUM(original_value) AS CHAR) AS sum_original_value,
CAST(SUM(rial_input) AS CHAR) AS sum_rial_input,
CAST(SUM(rial_output) AS CHAR) AS sum_rial_output,
CAST(SUM(price) AS CHAR) AS sum_price
FROM cashes WHERE deleted_at IS NULL;
-- به تفکیک نوع سند: کدام نوع از کدام ستون استفاده می‌کند؟
-- (type: 9=سفارش 1=آب‌شده 2=حواله 3=چک 4=پولی)
SELECT type AS cash_type,
COUNT(*) AS n,
SUM(input IS NOT NULL AND input <> 0) AS has_input,
SUM(output IS NOT NULL AND output <> 0) AS has_output,
SUM(value IS NOT NULL AND value <> 0) AS has_value,
SUM(rial_input IS NOT NULL AND rial_input <> 0) AS has_rial_in,
SUM(rial_output IS NOT NULL AND rial_output <> 0) AS has_rial_out,
SUM(price IS NOT NULL AND price <> 0) AS has_price
FROM cashes WHERE deleted_at IS NULL
GROUP BY type ORDER BY type;
-- آیا اصلاً ردیفی هست که هیچ مبلغی نداشته باشد؟
SELECT 'rows_with_no_amount_at_all' AS metric, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL
AND COALESCE(input,0)=0 AND COALESCE(output,0)=0
AND COALESCE(value,0)=0 AND COALESCE(rial_input,0)=0
AND COALESCE(rial_output,0)=0 AND COALESCE(price,0)=0;
-- ---------------------------------------------------------------------------
-- 🔴 سؤال ۲ — موبایل تکراری: آیا قاعده‌ی «مسئول فروش برنده» کافی است؟
--
-- ۲۷ گروه تکراری وجود دارد. اگر در یک گروه هر دو ردیف مشتری عادی باشند
-- (type != 1)، آن قاعده جواب نمی‌دهد و قاعده‌ی پشتیبان لازم است.
-- ---------------------------------------------------------------------------
SELECT '=== 2. DUPLICATE MOBILE — is the "sales expert wins" rule enough? ===' AS section;
-- در هر گروه تکراری، چند ردیف type=1 (کارشناس) هست؟
-- experts_in_group = 1 → قاعده جواب می‌دهد
-- experts_in_group = 0 → هیچ کارشناسی نیست، قاعده بی‌اثر → پشتیبان لازم
-- experts_in_group > 1 → چند کارشناس با یک شماره، قاعده مبهم
SELECT experts_in_group, COUNT(*) AS n_groups, SUM(rows_in_group) AS n_rows
FROM (
SELECT mobile,
COUNT(*) AS rows_in_group,
SUM(type = 1) AS experts_in_group
FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) g
GROUP BY experts_in_group ORDER BY experts_in_group;
-- آیا ردیف‌های تکراری updated_at متفاوت دارند؟ (قاعده‌ی پشتیبان پیشنهادی)
-- اگر n_ties > 0 یعنی updated_at هم تفکیک نمی‌کند
SELECT 'groups_where_updated_at_ties' AS metric, COUNT(*) AS n
FROM (
SELECT mobile, COUNT(*) AS c, COUNT(DISTINCT updated_at) AS d
FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1 AND COUNT(DISTINCT updated_at) < COUNT(*)
) t;
-- آیا ردیف‌های تکراری بدهی دارند؟ ادغامشان یعنی جمع‌کردن بدهی
SELECT 'dup_groups_with_debt' AS metric, COUNT(*) AS n
FROM (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile
HAVING COUNT(*) > 1 AND (SUM(COALESCE(remain,0)) <> 0 OR SUM(COALESCE(rial_remain,0)) <> 0)
) t;
-- آیا ردیف‌های تکراری سفارش دارند؟
SELECT 'dup_rows_with_orders' AS metric, COUNT(DISTINCT c.id) AS n
FROM customers c
JOIN orders o ON o.customer_id = c.id AND o.deleted_at IS NULL
WHERE c.deleted_at IS NULL AND c.mobile IN (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
);
-- ---------------------------------------------------------------------------
-- سؤال ۳ — نگاشت تصویر محصول: سه ستون → چهار نقش ProductImageRole
--
-- مرجع: §۹ — file_id / mobile_pic_id / indicator_id_id
-- ---------------------------------------------------------------------------
SELECT '=== 3. PRODUCT IMAGE ROLES ===' AS section;
SELECT 'file_id (FEATURED?)' AS col, COUNT(file_id) AS not_null FROM product WHERE deleted_at IS NULL
UNION ALL SELECT 'mobile_pic_id (FEATURED_MOBILE?)', COUNT(mobile_pic_id) FROM product WHERE deleted_at IS NULL
UNION ALL SELECT 'indicator_id_id (?)', COUNT(indicator_id_id) FROM product WHERE deleted_at IS NULL;
-- products_productimage همان GALLERY است؟ چند تصویر per محصول؟
SELECT 'gallery_rows' AS metric, COUNT(*) AS n FROM products_productimage WHERE deleted_at IS NULL
UNION ALL SELECT 'gallery_distinct_products', COUNT(DISTINCT product_id) FROM products_productimage WHERE deleted_at IS NULL
UNION ALL SELECT 'gallery_orphan_image', SUM(i.id IS NULL)
FROM products_productimage pi LEFT JOIN image i ON i.id = pi.image_id WHERE pi.deleted_at IS NULL
UNION ALL SELECT 'gallery_orphan_product', SUM(p.id IS NULL)
FROM products_productimage pi LEFT JOIN product p ON p.id = pi.product_id WHERE pi.deleted_at IS NULL;
-- ستون w در products_productimage — آیا نقش را تفکیک می‌کند؟
SELECT 'prod_image.w' AS col, COALESCE(w,'(null)') AS val, COUNT(*) AS n
FROM products_productimage WHERE deleted_at IS NULL GROUP BY w ORDER BY n DESC LIMIT 10;
-- یتیم‌های تصویر محصول (file_id → image)
SELECT 'product.file_id orphan' AS ref, COUNT(*) AS orphans
FROM product p LEFT JOIN image i ON i.id = p.file_id
WHERE p.deleted_at IS NULL AND p.file_id IS NOT NULL AND i.id IS NULL
UNION ALL
SELECT 'product.mobile_pic_id orphan', COUNT(*)
FROM product p LEFT JOIN image i ON i.id = p.mobile_pic_id
WHERE p.deleted_at IS NULL AND p.mobile_pic_id IS NOT NULL AND i.id IS NULL;
-- ---------------------------------------------------------------------------
-- سؤال ۴ — کارشناس فروش (type=1): ۵۶ ردیف که به AdminUser می‌روند
-- مرجع: §۵.۷ · D17
-- ---------------------------------------------------------------------------
SELECT '=== 4. SALES EXPERTS (type=1 -> AdminUser) ===' AS section;
SELECT 'experts_total' AS metric, COUNT(*) AS n FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_password', SUM(password IS NOT NULL AND password <> '') FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_ncode', SUM(ncode IS NOT NULL AND ncode <> '') FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_city', SUM(city_id IS NOT NULL) FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_percent', SUM(percent IS NOT NULL AND percent <> 0) FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_referenced_as_reagent', COUNT(DISTINCT reagent_id) FROM customers WHERE deleted_at IS NULL AND reagent_id IS NOT NULL;
-- 🔴 حیاتی: آیا reagent_id واقعاً به customers اشاره می‌کند؟ (§۵.۷)
-- اگر orphan_in_customers صفر یا کم باشد → تأیید می‌شود
SELECT 'reagent_id orphan vs customers' AS ref, COUNT(*) AS orphans
FROM customers c LEFT JOIN customers r ON r.id = c.reagent_id
WHERE c.deleted_at IS NULL AND c.reagent_id IS NOT NULL AND r.id IS NULL;
-- آیا همه‌ی reagent_id ها به ردیف type=1 اشاره می‌کنند؟
SELECT 'reagent points to non-expert' AS ref, COUNT(*) AS n
FROM customers c JOIN customers r ON r.id = c.reagent_id
WHERE c.deleted_at IS NULL AND r.type <> 1;
SELECT '=== END ===' AS section;
+321
View File
@@ -0,0 +1,321 @@
-- ============================================================================
-- پیش‌بررسی مهاجرت — فقط شمارش، صفر داده
-- ============================================================================
--
-- روی دیتابیس MySQL قدیمی (اسکیمای `oapi`) اجرا شود.
--
-- ⚠️ این فایل فقط SELECT COUNT / SUM / MIN / MAX دارد.
-- هیچ INSERT / UPDATE / DELETE / DROP / ALTER در آن نیست.
-- هیچ ستون متنی (نام، موبایل، کد ملی، آدرس) در خروجی ظاهر نمی‌شود.
--
-- اجرا:
-- mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck.sql > out.txt
--
-- سپس محتوای out.txt کپی شود. خروجی فقط عدد است.
--
-- مرجع: docs/DATA-MIGRATION-MAP.md §۳ · §۵.۳ · §۵.۵ · §۵.۶
-- ============================================================================
-- ---------------------------------------------------------------------------
-- بخش ۱ — حجم کار: تعداد ردیف زنده و حذف‌شده‌ی نرم
-- مرجع: §۵.۶ — ردیف حذف‌شده نباید منتقل شود
-- ---------------------------------------------------------------------------
SELECT '=== 1. ROW COUNTS ===' AS section;
SELECT 'customers' AS tbl, COUNT(*) AS live, SUM(deleted_at IS NOT NULL) AS soft_deleted FROM customers
UNION ALL SELECT 'users', COUNT(*), SUM(deleted_at IS NOT NULL) FROM users
UNION ALL SELECT 'orders', COUNT(*), SUM(deleted_at IS NOT NULL) FROM orders
UNION ALL SELECT 'order_items', COUNT(*), SUM(deleted_at IS NOT NULL) FROM order_items
UNION ALL SELECT 'cashes', COUNT(*), SUM(deleted_at IS NOT NULL) FROM cashes
UNION ALL SELECT 'product', COUNT(*), SUM(deleted_at IS NOT NULL) FROM product
UNION ALL SELECT 'variant', COUNT(*), SUM(deleted_at IS NOT NULL) FROM products_productvarient
UNION ALL SELECT 'prod_image', COUNT(*), SUM(deleted_at IS NOT NULL) FROM products_productimage
UNION ALL SELECT 'category', COUNT(*), SUM(deleted_at IS NOT NULL) FROM category_collection
UNION ALL SELECT 'image', COUNT(*), SUM(deleted_at IS NOT NULL) FROM image
UNION ALL SELECT 'cities', COUNT(*), SUM(deleted_at IS NOT NULL) FROM cities
UNION ALL SELECT 'branches', COUNT(*), SUM(deleted_at IS NOT NULL) FROM branches
UNION ALL SELECT 'workshops', COUNT(*), SUM(deleted_at IS NOT NULL) FROM workshops
UNION ALL SELECT 'custom_orders', COUNT(*), SUM(deleted_at IS NOT NULL) FROM custom_orders
UNION ALL SELECT 'comments', COUNT(*), SUM(deleted_at IS NOT NULL) FROM comments
UNION ALL SELECT 'messages', COUNT(*), 0 FROM messages
UNION ALL SELECT 'notify', COUNT(*), SUM(deleted_at IS NOT NULL) FROM notify
UNION ALL SELECT 'fav', COUNT(*), 0 FROM fav
UNION ALL SELECT 'permission', COUNT(*), 0 FROM permission
UNION ALL SELECT 'config_statics', COUNT(*), SUM(deleted_at IS NOT NULL) FROM config_statics;
-- ---------------------------------------------------------------------------
-- بخش ۲ — 🔴 یکتایی: آیا @unique در Prisma می‌شکند؟
-- مرجع: §۳ — مبدأ هیچ unique index ندارد
-- هر عدد غیرصفر یعنی مهاجرت در میانه متوقف می‌شود
-- ---------------------------------------------------------------------------
SELECT '=== 2. UNIQUENESS (any nonzero = migration breaks) ===' AS section;
-- چند *گروه* موبایل تکراری، و چند ردیف درگیرند
SELECT 'dup_mobile_groups' AS metric, COUNT(*) AS n FROM (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d
UNION ALL
SELECT 'dup_mobile_rows', COALESCE(SUM(c),0) FROM (
SELECT COUNT(*) AS c FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d2
UNION ALL
SELECT 'dup_ncode_groups', COUNT(*) FROM (
SELECT ncode FROM customers
WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode <> ''
GROUP BY ncode HAVING COUNT(*) > 1
) d3
UNION ALL
SELECT 'dup_ncode_rows', COALESCE(SUM(c),0) FROM (
SELECT COUNT(*) AS c FROM customers
WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode <> ''
GROUP BY ncode HAVING COUNT(*) > 1
) d4;
-- تهی‌بودن: Prisma هر دو را NOT NULL می‌خواهد
SELECT 'empty_mobile' AS metric, SUM(mobile IS NULL OR mobile = '') AS n FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'empty_ncode', SUM(ncode IS NULL OR ncode = '') FROM customers WHERE deleted_at IS NULL;
-- تداخل شماره بین مشتری و کاربر ادمین (D21: «کارشناس برنده است»)
SELECT 'mobile_collision_customer_vs_user' AS metric, COUNT(*) AS n
FROM customers c JOIN users u ON u.mobile = c.mobile
WHERE c.deleted_at IS NULL AND u.deleted_at IS NULL
AND c.mobile IS NOT NULL AND c.mobile <> '';
-- یکتایی در جدول users هم بررسی شود
SELECT 'dup_user_mobile_groups' AS metric, COUNT(*) AS n FROM (
SELECT mobile FROM users
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d;
-- ---------------------------------------------------------------------------
-- بخش ۳ — 🔴 نام: آیا D0 درست است؟
-- مرجع: §۲ — fname/lname از قبل وجود دارند
-- ---------------------------------------------------------------------------
SELECT '=== 3. NAME PARTS (does `name` agree with fname+lname?) ===' AS section;
SELECT
SUM(fname IS NULL OR fname = '') AS empty_fname,
SUM(lname IS NULL OR lname = '') AS empty_lname,
SUM(name IS NULL OR name = '') AS empty_name,
-- ردیف‌هایی که name با اجزا نمی‌خواند → trigger مقصد name را بازنویسی می‌کند
SUM(TRIM(COALESCE(name,'')) <> TRIM(CONCAT(COALESCE(fname,''),' ',COALESCE(lname,'')))) AS name_mismatch,
-- اجزا تهی ولی name پر → تنها حالتی که تجزیه لازم است
SUM((fname IS NULL OR fname = '') AND (lname IS NULL OR lname = '')
AND name IS NOT NULL AND name <> '') AS parts_empty_name_set
FROM customers WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۴ — enum ها: آیا مقداری بی‌نگاشت می‌ماند؟
-- مرجع: §۵.۳ (type) · §۵.۵ (verify) · §۷.۳ (sts)
-- ---------------------------------------------------------------------------
SELECT '=== 4. ENUMS ===' AS section;
-- type: 1..5 نگاشت دارند. NULL یا خارج از بازه → بی‌صدا APP_SHOPKEEPER می‌شود
SELECT 'customers.type' AS col, CAST(type AS CHAR) AS val, COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL GROUP BY type ORDER BY type;
-- sts و active: دو سوییچ مجزا
SELECT 'customers.sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL GROUP BY sts
UNION ALL
SELECT 'customers.active', CAST(active AS CHAR), COUNT(*)
FROM customers WHERE deleted_at IS NULL GROUP BY active
UNION ALL
SELECT 'customers.is_archive', CAST(is_archive AS CHAR), COUNT(*)
FROM customers WHERE deleted_at IS NULL GROUP BY is_archive;
-- دو ستون تأیید → یک enum چهارحالته. حالت (0,1) در مقصد نماینده ندارد
SELECT 'verify_step_combo' AS col,
CONCAT(COALESCE(CAST(sts_verify_step_one AS CHAR),'NULL'), ',',
COALESCE(CAST(sts_verify_step_two AS CHAR),'NULL')) AS val,
COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL
GROUP BY sts_verify_step_one, sts_verify_step_two;
-- وضعیت سفارش عمده: 0..6 نگاشت دارند
SELECT 'orders.sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM orders WHERE deleted_at IS NULL GROUP BY sts ORDER BY sts;
-- اسناد مالی: type → LedgerSourceType (9,1,2,3,4)، confirmed حیاتی است (D24)
SELECT 'cashes.type' AS col, CAST(type AS CHAR) AS val, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL GROUP BY type ORDER BY type;
SELECT 'cashes.confirmed' AS col, CAST(confirmed AS CHAR) AS val, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL GROUP BY confirmed;
-- ---------------------------------------------------------------------------
-- بخش ۵ — 🔴 پول و وزن: مبنای تطبیق + تأیید اینکه آستانه لازم است
-- مرجع: §۴ — ۱۱۰ ستون double، «صفر اختلاف» دست‌نیافتنی است
-- ---------------------------------------------------------------------------
SELECT '=== 5. MONEY / WEIGHT TOTALS (migration must match these) ===' AS section;
SELECT
COUNT(*) AS n_customers,
CAST(SUM(remain) AS CHAR) AS sum_gold_debt,
CAST(SUM(rial_remain) AS CHAR) AS sum_rial_debt,
CAST(SUM(gram_level) AS CHAR) AS sum_credit_limit,
SUM(remain <> 0) AS n_nonzero_gold,
SUM(rial_remain <> 0) AS n_nonzero_rial,
SUM(remain < 0) AS n_negative_gold,
SUM(rial_remain < 0) AS n_negative_rial
FROM customers WHERE deleted_at IS NULL;
-- 🔴 شاهدِ مشکل double: چند ردیف مقداری دارند که در Decimal(14,4) جا نمی‌شود؟
-- عدد غیرصفر = گِردکردن رخ می‌دهد = «صفر اختلاف» ناممکن است
SELECT 'gold_needs_rounding' AS metric, SUM(remain <> ROUND(remain, 4)) AS n
FROM customers WHERE deleted_at IS NULL
UNION ALL
SELECT 'credit_needs_rounding', SUM(gram_level <> ROUND(gram_level, 4))
FROM customers WHERE deleted_at IS NULL
UNION ALL
SELECT 'order_weight_needs_rounding', SUM(w <> ROUND(w, 4))
FROM orders WHERE deleted_at IS NULL
UNION ALL
SELECT 'cash_amount_needs_rounding', SUM(remain <> ROUND(remain, 4))
FROM cashes WHERE deleted_at IS NULL;
-- فقط اسناد تأییدشده در موجودی حساب می‌شوند (D24)
SELECT 'confirmed_only' AS scope,
CAST(SUM(remain) AS CHAR) AS sum_gold,
CAST(SUM(rial_remain) AS CHAR) AS sum_rial,
COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL AND confirmed = 1;
-- ---------------------------------------------------------------------------
-- بخش ۶ — یکپارچگی ارجاعی (مبدأ صفر FK دارد — §۱۱)
-- ارجاع یتیم = ردیفی که مهاجرت نمی‌تواند وصل کند
-- ---------------------------------------------------------------------------
SELECT '=== 6. ORPHAN REFERENCES (no FKs exist in source) ===' AS section;
SELECT 'customer.city_id -> cities' AS ref, COUNT(*) AS orphans
FROM customers c LEFT JOIN cities ci ON ci.id = c.city_id
WHERE c.deleted_at IS NULL AND c.city_id IS NOT NULL AND ci.id IS NULL
UNION ALL
SELECT 'customer.reagent_id -> users', COUNT(*)
FROM customers c LEFT JOIN users u ON u.id = c.reagent_id
WHERE c.deleted_at IS NULL AND c.reagent_id IS NOT NULL AND u.id IS NULL
UNION ALL
SELECT 'customer.image_id -> image', COUNT(*)
FROM customers c LEFT JOIN image i ON i.id = c.image_id
WHERE c.deleted_at IS NULL AND c.image_id IS NOT NULL AND i.id IS NULL
UNION ALL
SELECT 'order.customer_id -> customers', COUNT(*)
FROM orders o LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.deleted_at IS NULL AND o.customer_id IS NOT NULL AND c.id IS NULL
UNION ALL
SELECT 'order_items.order_id -> orders', COUNT(*)
FROM order_items oi LEFT JOIN orders o ON o.id = oi.order_id
WHERE oi.deleted_at IS NULL AND oi.order_id IS NOT NULL AND o.id IS NULL
UNION ALL
SELECT 'order_items.variant_id -> variant', COUNT(*)
FROM order_items oi LEFT JOIN products_productvarient v ON v.id = oi.variant_id
WHERE oi.deleted_at IS NULL AND oi.variant_id IS NOT NULL AND v.id IS NULL
UNION ALL
SELECT 'cashes.customer_id -> customers', COUNT(*)
FROM cashes ca LEFT JOIN customers c ON c.id = ca.customer_id
WHERE ca.deleted_at IS NULL AND ca.customer_id IS NOT NULL AND c.id IS NULL
UNION ALL
SELECT 'variant.product_id_id -> product', COUNT(*)
FROM products_productvarient v LEFT JOIN product p ON p.id = v.product_id_id
WHERE v.deleted_at IS NULL AND v.product_id_id IS NOT NULL AND p.id IS NULL
UNION ALL
SELECT 'fav.customer_id -> customers', COUNT(*)
FROM fav f LEFT JOIN customers c ON c.id = f.customer_id
WHERE c.id IS NULL
UNION ALL
SELECT 'fav.product_id -> product', COUNT(*)
FROM fav f LEFT JOIN product p ON p.id = f.product_id
WHERE p.id IS NULL;
-- 🔴 fav هیچ PK ندارد → ردیف تکراری ممکن است (§۱۰)
SELECT 'fav_duplicate_pairs' AS metric, COUNT(*) AS n FROM (
SELECT customer_id, product_id FROM fav
GROUP BY customer_id, product_id HAVING COUNT(*) > 1
) d;
-- permission هم PK ندارد
SELECT 'permission_duplicate_rows' AS metric, COUNT(*) AS n FROM (
SELECT rid, ut FROM permission GROUP BY rid, ut HAVING COUNT(*) > 1
) d;
-- ---------------------------------------------------------------------------
-- بخش ۷ — ستون‌های مبهم: نوع واقعی داده
-- مرجع: §۸ — ayar متنی یا عددی؟ تاریخ جلالی یا میلادی؟
-- ---------------------------------------------------------------------------
SELECT '=== 7. AMBIGUOUS COLUMNS ===' AS section;
-- ayar در مبدأ varchar(20) است ولی مقصد Int? — مقدار غیرعددی می‌شکند
SELECT 'ayar_total' AS metric, COUNT(*) AS n FROM cashes WHERE deleted_at IS NULL AND ayar IS NOT NULL AND ayar <> ''
UNION ALL
SELECT 'ayar_non_numeric', COUNT(*) FROM cashes
WHERE deleted_at IS NULL AND ayar IS NOT NULL AND ayar <> '' AND ayar NOT REGEXP '^[0-9]+$';
-- recive_date / proceed_at در مبدأ varchar هستند — جلالی یا میلادی؟
-- طول رشته و اینکه با 13 یا 14 شروع می‌شود، نوع تقویم را لو می‌دهد
SELECT 'recive_date_set' AS metric, COUNT(*) AS n FROM cashes WHERE deleted_at IS NULL AND recive_date IS NOT NULL AND recive_date <> ''
UNION ALL
SELECT 'recive_date_starts_13x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '13%'
UNION ALL
SELECT 'recive_date_starts_14x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '14%'
UNION ALL
SELECT 'recive_date_starts_20x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '20%'
UNION ALL
SELECT 'proceed_at_set', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND proceed_at IS NOT NULL AND proceed_at <> '';
-- ستون‌های داده‌ی غیرقابل‌بازسازی (§۵.۲ گروه ج) — چند ردیف واقعاً مقدار دارند؟
SELECT 'has_birth_date' AS metric, COUNT(*) AS n FROM customers WHERE deleted_at IS NULL AND birth_date IS NOT NULL
UNION ALL SELECT 'has_marriage_date', COUNT(*) FROM customers WHERE deleted_at IS NULL AND marriage_date IS NOT NULL
UNION ALL SELECT 'has_token_mobile', COUNT(*) FROM customers WHERE deleted_at IS NULL AND token_mobile IS NOT NULL AND token_mobile <> ''
UNION ALL SELECT 'has_new_city', COUNT(*) FROM customers WHERE deleted_at IS NULL AND new_city IS NOT NULL AND new_city <> '';
-- فرمت رمز عبور: bcrypt با $2y$ شروع می‌شود. اگر نه، hash دیگری است
SELECT 'pw_bcrypt' AS metric, SUM(password LIKE '$2%') AS n FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'pw_other', SUM(password IS NOT NULL AND password <> '' AND password NOT LIKE '$2%') FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'pw_empty', SUM(password IS NULL OR password = '') FROM customers WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۸ — جداول پشتیبان: آیا ردیفی دارند که `customers` ندارد؟
-- مرجع: §۱.۱ — قبل از نادیده‌گرفتن باید شمرده شوند
-- ---------------------------------------------------------------------------
SELECT '=== 8. BACKUP TABLES (safe to ignore?) ===' AS section;
SELECT 'customers' AS tbl, COUNT(*) AS n, CAST(MAX(id) AS CHAR) AS max_id FROM customers
UNION ALL SELECT 'customers_copy1', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_copy1
UNION ALL SELECT 'customers_copy2', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_copy2
UNION ALL SELECT 'customers_test', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_test;
-- شناسه‌هایی که در کپی هستند ولی در جدول اصلی نیستند → یعنی ردیف حذف شده
SELECT 'ids_only_in_copy1' AS metric, COUNT(*) AS n
FROM customers_copy1 k LEFT JOIN customers c ON c.id = k.id WHERE c.id IS NULL
UNION ALL
SELECT 'ids_only_in_copy2', COUNT(*)
FROM customers_copy2 k LEFT JOIN customers c ON c.id = k.id WHERE c.id IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۹ — تک‌فروشی (به تأخیر افتاده، §۷.۲) — فقط حجم، برای برنامه‌ریزی آینده
-- ---------------------------------------------------------------------------
SELECT '=== 9. RETAIL (deferred — size only) ===' AS section;
SELECT 'orders_orders' AS tbl, COUNT(*) AS live, SUM(deleted_at IS NOT NULL) AS soft_deleted FROM orders_orders
UNION ALL SELECT 'orders_orderitem', COUNT(*), SUM(deleted_at IS NOT NULL) FROM orders_orderitem
UNION ALL SELECT 'login', COUNT(*), SUM(deleted_at IS NOT NULL) FROM login;
-- 🔴 sts=7 در خرده «تحویل مشتری» است ولی در عمده «کنسل». چند ردیف درگیرند؟
SELECT 'retail_sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM orders_orders WHERE deleted_at IS NULL GROUP BY sts ORDER BY sts;
SELECT '=== END ===' AS section;
+57
View File
@@ -1361,3 +1361,60 @@ against the real `jsonwebtoken` and asserts the reader can no longer produce it.
**Standing rule from here on:** read an environment variable with `envOr` /
`envOrNull`, never with `??`, unless an empty string is a meaningful value for
that specific variable.
## D26 — SMS through the owner's relay gateway, drivers per provider, 2026-10-05
The owner supplied the PHP client of their own SMS gateway — a relay at
`http://185.231.112.101/api/createsms` that handles SMS for several panels and
apps. Owner rulings:
| Topic | Ruling |
|---|---|
| Default | The relay **replaces Trez as the default**. Trez is **kept**, selectable with `SMS_PROVIDER=trez`; more providers may follow. |
| Secret | The `secret_key` **lives in source** as the default. The gateway has a second verification factor held elsewhere, so the owner does not treat it as sensitive. `SMS_RELAY_SECRET` overrides it. (Contrast SEC-12, where the Trez credentials stay env-only.) |
| Platform | **Every** message goes through Kavenegar (`platform: "kave"`). |
| Host | The server's own IP is correct, as in the PHP client; `SMS_RELAY_HOST` overrides it. |
| Address | The IP over HTTP is correct. HTTPS on that IP presents an expired `*.montpar.ir` certificate (checked 2026-10-05), so HTTPS is not usable on the IP; `SMS_RELAY_URL` can switch later. |
| Replies | Undocumented; left to the implementation. A 2xx counts as sent unless the JSON body says `status: false`, `success: false`, `ok: false`, `status: "error"`/`"failed"` or carries `error`. |
Implementation: `apps/api/src/modules/sms/drivers/` — one `SmsDriver` per
provider (`relay.driver.ts`, `trez.driver.ts`) registered in `drivers/index.ts`.
A new provider is one file plus one registry line. `SmsService` keeps the
cross-provider rules: trim, persist every message (SEC-11 for OTP unchanged),
log failures and never throw (D20). Plan: `docs/SMS-RELAY-PLAN.md`.
Consequence: with no SMS variables set, **every environment now sends real
SMS**, local development included. Set `SMS_PROVIDER=none` on a machine that
must not send.
## D27 — accounting integration (Tahesab / Zarrin), switchable from the admin panel, 2026-10-05
The owner wants the app connected to the shop's accounting software, and the
software differs per installation: **Tahesab** or **Zarrin**, picked with one
switch in the admin panel (`none | tahesab | zarrin`). Plan and the full API
survey: `docs/ACCOUNTING-INTEGRATION-PLAN.md`. Owner rulings:
| Topic | Ruling |
|---|---|
| Scope | Customer sync, balance sync, order documents, and ready-made items (کارساخته) ↔ products. **Everything else stays in the app** — repayment documents (melted gold, cash, cheque) are not sent. |
| Balance | The accounting balance is **shown to the customer**; the **credit-limit check stays on the internal ledger**. |
| Report | «گزارش» reads from the accounting software when the integration is on, so it agrees with the balance shown. Supersedes D24 for that case only; D24 remains the fallback. |
| Customers | Created in the accounting software when an admin marks them **VERIFIED**; existing ones are linked by **mobile**. Accounts that exist only in the accounting software are **created in the app**, keyed by mobile (national code falls back to the mobile, as elsewhere). |
| Documents | Sent at the debt-recognition point (`DELIVERED_TO_AGENT`) as **temporary** (موقت) documents for the accountant to finalise. Ready-made items only for now; gold/coin trading later. |
| Products | One accounting کارساخته = one app **Product**. Title, code and stock belong to the accounting software; images, description, variants, category and **making charge belong to the app**. |
| Visibility | **A product with no image is never shown in the app** — all products, automatically. |
| Stock | Accounting stock is shown in the admin panel; at zero the product is unavailable in the app (pre-order variants excepted). |
Security carried over from the reference's mistakes (SEC-06/16/17): TLS
verification is never disabled (a custom CA can be supplied instead), the
token is write-only and encrypted at rest with `ACCOUNTING_SECRET_KEY`, the
generic `const` settings routes refuse `accounting.*` keys, and statement
details are scoped to the token's own customer.
Later rulings, same day (plan §11–§12, ACC-08/09): sync is real-time (change
feed polled every 5 s, sends right after commit, order-time live stock that
falls back to the last known figure); sync buttons on the wholesale-customer
and product lists and a live balance on the app profile, all working whenever
a provider is connected regardless of the feature switches; and the order
status that posts the temporary document (پیش‌فاکتور) is an admin-chosen rule
(`documents.triggerStatus`, default «تحویل مسئول فروش»).
+17 -3
View File
@@ -132,7 +132,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. |
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. |
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. |
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | Structural change; decide with the owner during the cities screen (Phase 1c). |
| **PERS-03** | Low | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. |
@@ -162,9 +162,9 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **SEC-16** | High (reference bug, NOT ported) | REF `Customer/TahesabController.php::detail()` | The document-detail endpoint took the factor code straight from the URL and performed **no ownership check at all** — the method never consults `auth()`. Any signed-in customer could read any other customer's accounting document by guessing or enumerating a code. | `public function detail(Request $request, $id)` → proxies `GetHesabDetailByFactorCode?Factor_Code=$id` with no customer scope | **Not ported.** In the target the id is only a filter on top of a `customerId` scope taken from the token, so another customer's id returns nothing. Asserted in both directions by `test-report.cjs` §6. |
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. |
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. **Update 2026-10-05 (D27):** with the accounting integration on, «گزارش» reads the accounting statement and `nO_kar` / `jens_felez` are filled from it. |
| **REP-02** | Low (performance) | TGT `customer-report.service.ts` `summary()` | The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. | `summary()` `findMany` without `take` | If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time. |
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. |
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. **Update 2026-10-05 (D27):** a Tahesab integration now exists; it keeps TLS verification on (a custom CA can be supplied), stores the token encrypted and write-only, and never logs it. |
## Found by the pre-delivery Docker Compose run (2026-09-23)
@@ -172,3 +172,17 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|---|---|---|---|---|---|
| **DEPLOY-02** | ~~**Critical**~~ **FIXED** | TGT `auth.module.ts`, `customer-auth.service.ts`, `jwt.strategy.ts`, `main.ts`, `upload-path.ts`, `sms.service.ts` | **Admin login returned 500 for every user in Docker.** `docker-compose.yml` passes each optional variable as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code read them with `process.env.X ?? 'default'`, which only falls back on `undefined` — so the empty string won and `jsonwebtoken` threw `"expiresIn" should be a number of seconds or string representing a timespan` on **every** sign. Invisible to every suite that ran against a local dev process, where the variable is genuinely absent. The same pattern also meant a blank `UPLOAD_DIR` would have served static files from the **filesystem root**, and a blank `PORT` would have bound a random ephemeral port (`Number('') === 0`), making the container unreachable. | Container log: `ERROR [ExceptionsHandler] "expiresIn" should be a number of seconds or string representing a timespan` | ✅ Added `src/common/env.ts` (`envOr` / `envOrNull`) which treats blank as unset, and applied it to every auth, port, upload and SMS read. Pinned by `src/common/env.spec.ts` (9 tests), which reproduces the original throw against the real `jsonwebtoken` and asserts the reader can no longer produce it. Re-verified end to end in compose: **32/32**. |
| **DEPLOY-03** | Medium (process) | TGT `apps/api/Dockerfile` | The image is `COPY release/ ./` — it ships a **pre-built bundle** produced on the developer machine by `pnpm build:release`, not a build performed inside the image. So `docker compose build` happily produces an image from a **stale** `release/app.js` and reports success. At the start of this run that bundle was four days old, and the rebuilt container was missing every route added since. | `apps/api/release/app.js` dated Sep 19 while the source was Sep 23; the container mapped none of the new routes | **`pnpm build:release` MUST be run before `docker compose build`.** Noted in DELIVERY.md's deploy section. A longer-term fix is to build inside the Dockerfile, or to have the build fail when `release/` is older than `src/`. |
## Accounting integration — found while building D27 (2026-10-05)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **ACC-01** | Medium (unverified) | TGT `accounting-sync/providers/zarrin.provider.ts` | The Zarrin driver follows the published docs only — there is no Zarrin installation to test on. Open points: which sign of `listMandeHesabha` means "owes" (a setting, default negative), whether stones belong in `mablagh` or `mablaghSang` on `foroushSakhte` (sent in `mablagh`), and the exact response envelopes. | Owner: «نه، فعلاً نداریم» (no test install). Unit tests use the docs' sample outputs. | Before real use: a trial on temporary documents on a real install (owner's decision 11), check one balance and one document against the Zarrin UI. |
| **ACC-02** | Low (by design) | TGT D27 | Order documents are sent **temporary** (owner ruling), and a temporary Tahesab document does **not** move the balance until the accountant finalises it. Until then the balance the customer sees from the accounting software does not include those orders. | Measured on the Tahesab test server: balance delta 0 for a temporary `VKHKar`, −Vazn×Ayar/750 once final. | Accountant finalises documents promptly; the report marks temporary rows «موقت — هنوز در مانده اثر ندارد». |
| **ACC-03** | **High** (pre-existing, NOT fixed — ledger is protected) | TGT `orders/orders.service.ts` `updateStatus()` | The debt guard is `DELIVERED_TO_AGENT && !order.deliveredAt`, but `deliveredAt` is only set at `DELIVERED_TO_CUSTOMER`. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt **twice**. | Read at `orders.service.ts:135-160`. The new accounting hook is not affected (it dedupes on the existing document row). | Guard on an existing `ORDER_STOCK` ledger entry for the order instead of `deliveredAt`; needs owner approval (PROTECTED area). |
| **ACC-04** | Low (pre-existing) | TGT `src/prisma/migrate.ts` | On a completely empty database `runMigrations` fails: `0002_admin_schema.sql` sorts before `20260910113720_first` and alters tables that do not exist yet. Existing databases are unaffected. | Reproduced while preparing the D27 throwaway test database (`relation "City" does not exist`). | Rename to sort after `first`, with a `LEGACY_NAME_ALIASES` entry so applied databases do not re-run it. |
| **ACC-05** | Low (behaviour change) | TGT `catalog.service.ts` | D27 rule: a product with **no image** is no longer listed in the app nor orderable — for every product, not only those from the accounting software. | Dev DB on 2026-10-05: 0 of 5 active products affected. | Before release run `select count(*) from "Product" p where p."isActive" and not exists (select 1 from "ProductImage" i where i."productId" = p.id);` on production and photograph those first. |
| **ACC-06** | Medium (fixed 2026-10-05) | TGT `accounting-sync/accounting-http.ts` | A real Tahesab install reached by IP could not connect. Tahesab serves its own self-signed certificate issued for `localhost` / `127.0.0.1`, so verification failed (`DEPTH_ZERO_SELF_SIGNED_CERT`), and pasting it as a CA still failed the name check (`ERR_TLS_CERT_ALTNAME_INVALID`). «تست اتصال» also tested only *saved* settings. | Owner's server (`https://<ip>:4040`, DBname `tahesabDB5`): token and DB were fine (raw `CheckHealth` = OK). The same server also answers to the default `TahesabDB` — a different database (40 customer groups instead of 19). | Fixed: a pasted certificate is now also a **pin** — exactly that certificate is accepted under any name; the chain check stays on, any other certificate is still name-checked, and verification is never switched off. New «دریافت گواهی از سرور» button shows the SHA-256 fingerprint to compare before pinning; «تست اتصال» uses the form as typed. Verified against the owner's server; `accounting-http.spec.ts`. Admins must check the DB name in the test result. |
| **ACC-07** | Medium (fixed 2026-10-05) | TGT `accounting-sync` (config, worker, admin) | "Connected but nothing syncs": the switch was saved on «زرین» while only Tahesab had an address/token. The worker found no usable provider and did nothing, with no message; «سینک الان» answered "started". Separately, an empty «گروه‌های مشتری برای ورود» meant *all groups* — on the owner's server that includes صندوق, کارمندان, سرمایه‌گذار, آبشده, کلاهبرداران… which would have become app customers. | Owner's Tahesab: 228 accounts in 15 groups, 23 کارساخته; read-only provider calls (groups, customers, balances, items, stock) all fine. | Fixed: saving a provider without address/token is refused; the screen shows why nothing syncs (`inactiveReason`); «سینک الان» refuses with the reason (or "feature off"). Customer pull now refuses to run with no groups selected; creating a customer with no «گروه مشتری جدید» goes to NEEDS_REVIEW instead of an ungrouped account. |
| **ACC-08** | Medium (done 2026-10-05) | TGT `accounting-sync` (worker, providers, sync, catalog) | Owner: sync must be real-time, not delayed or manual. It was: outbox tick 30 s, balances every 10 min, products every 30 min, stock read from the stored copy at order time. | Neither Tahesab nor Zarrin has webhooks. Tahesab `DoListUpdatedAsnad` lists new/edited documents (≈400 ms); proven on the public test server (document posted → account+item seen in the next poll, 0.7 s). Zarrin `parLastTenRadif` is unverified (ACC-01). | Done: fast loop (2 s) for outbox + change poll every 5 s (fixed; the admin setting was removed at the owner's request); `kickSoon()` right after a hook enqueues; targeted balance/stock refresh; order-time live stock with 2.5 s timeout, falling back to the last known stock (owner's ruling); balance freshness 5 s. Interval jobs stay as the safety net (deletions, edits, new customers). `getChanges` tests added (55 accounting tests). See plan §11. |
| **ACC-09** | Medium (done 2026-10-05) | TGT `accounting-sync`, `orders`, `admin-orders`, admin lists, both apps' profile | Owner: sync also on demand — a button on the wholesale-customer list and the product list; the app profile reads its own balance live on every visit; an optional rule for the order status that posts the temporary document (پیش‌فاکتور). | Owner: buttons/profile work whenever connected, regardless of the feature switches; the rule lives on «اتصال حسابداری». Admin order delete and status rollback could leave a temporary document behind once it can be posted before the debt. | Done: `AccountingQuickController` (`admin/accounting-status`, `admin/customers-omde/accounting-sync`, `admin/products/accounting-sync`, `customer/me/accounting-balance`), `documents.triggerStatus` (default DELIVERED_TO_AGENT), `orderStatusReached` hook from createOrder/updateStatus/rollback, withdrawal on delete/rollback, delete job independent of the order row. 8 hook tests. Plan §12. |
+1
View File
@@ -10,6 +10,7 @@
|---|---|---|
| `docs/GO-LIVE-PLAN.md` | Stack upgrade, release isolation, data migration and cutover from the live henza system. **§0 explains how to continue on another machine.** | Owner go-ahead per step |
| `docs/PWA-PORT-PLAN.md` | Third user app `apps/user-app-classic`: a pixel-exact port of `henza_pwa@b171fff` onto the new API. | Owner rulings on its tensions 1–5 and Q1–Q9 |
| `docs/ACCOUNTING-INTEGRATION-PLAN.md` | Tahesab / Zarrin integration, switchable in the admin panel (D27). **Implemented 2026-10-05**, off until switched on; progress in its §10. | Zarrin trial on a real install (ACC-01); ACC-03 ruling |
The "Current state" table below is the parity project's and was last updated 2026-09-22.