Files
tehran_tala/docs/ADMIN-PORT-PLAN.md
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

1022 lines
85 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# پلن پورت پنل ادمین — henza_admin → apps/admin-panel
سند برنامه‌ریزی. **قبل از تأیید شما هیچ کدی نوشته نمی‌شود.**
## قواعد حاکم بر کل کار (تکرار خواسته‌ی شما)
1. **بدون دست خوردن** — منطق، فرمول‌ها، نام فیلدها، متن‌های فارسی، ارقام (فارسی/لاتین)، و رفتار RTL دقیقاً مثل legacy.
2. **باگ‌های legacy را بی‌صدا درست نمی‌کنم** — وفادار پورت می‌کنم و جداگانه به شما گزارش می‌دهم.
3. **`henza_admin/`, `henza_server/`, `henza_pwa/` فقط خواندنی** — هرگز تغییر نمی‌کنند.
4. **بخش به بخش** — هر Batch یک checkpoint دارد؛ بدون تأیید شما به Batch بعدی نمی‌روم.
5. هر Batch شامل **هم endpoint های NestJS و هم view های Vue 3** است (تصمیم شما: «بک‌اند همراه هر بخش»).
---
## دامنه‌ی واقعی کار (اعداد راستی‌آزمایی‌شده)
| سنجه | عدد | نحوه‌ی راستی‌آزمایی |
|---|---|---|
| کل فایل `.vue` در `views/` | 541 | `find views -name '*.vue' \| wc -l` |
| **view های واقعاً قابل دسترس از روتر زنده** | **69** | استخراج از `router/admin.js` + `router/branch.js` (تصحیح‌شده — رجوع به یادداشت زیر) |
| LOC این ۶۹ view | **18,728** | شمارش برنامه‌ای روی هر ۶۹ فایل |
| فایل روتر با ۰ importer (مرده) | 8 از 10 | حلقه‌ی جست‌وجوی importer |
| route با `meta.role` | 65 (admin) + 14 (branch) | `grep -c` |
| **rid یکتای مصرف‌شده در روتر** | **58** | `grep -oE "role: '...'" \| sort -u` |
| endpoint یکتای API در views+components | 114 | استخراج literal ها |
| کامپوننت `.vue` در `components/` | 56 | `find components` |
| از آن‌ها: fork های تکراری XTable | 4 نسخه | `x-table/`, `x-table_select/`, `custom-xtable-select/`, `backup-table/` |
| schema ستون در `src/table/` | 18 فایل | `ls table/` |
| ماژول Vuex ثبت‌شده در `store.js` | **11** از 15 پوشه | خواندن `store.js` |
**۴۸۹ فایل باقی‌مانده = دموهای قالب Vuexy** (`views/components/vuesax/*`, `views/forms/*`, `views/charts-and-maps/*`, `views/ui-elements/*`, `views/apps/{chat,email,todo,calendar}`) — طبق تصمیم شما پورت نمی‌شوند.
---
## دو تنشی که باید الان تصمیم‌شان را بدانید
### تنش ۱ — «بدون دست خوردن» در برابر کد ۹۵٪ تکراری
پنج فایل لیست عمده تقریباً یکسان‌اند:
```
BankerList.vue 620
ShopkeeperList.vue 614
OmdeList.vue 614
OmdeNonVerifiedList.vue 612
OmdeArchivedList.vue 612
------
3,072 LOC
```
تفاوت واقعی‌شان فقط در یک فیلتر و چند ستون است. همچنین ۴ نسخه‌ی XTable (~۱٬۳۰۰ LOC) و ۷ کامپوننت نازک Edit.
**پیشنهاد من:** رفتار و ظاهر ۱۰۰٪ حفظ شود، ولی ۵ لیست عمده روی یک کامپوننت مشترک با prop های config سوار شوند و فقط یک XTable نگه داشته شود (نسخه‌ی `x-table/` که کامل‌ترین است و `QuerySelectTableFilter` دارد). خروجی کاربر ذره‌ای فرق نمی‌کند.
**گزینه‌ی جایگزین:** هر ۵ فایل عیناً و جداگانه پورت شوند (وفاداری فایل‌به‌فایل، ولی ۳٬۰۰۰ خط تکراری در پروژه‌ی نو).
### تنش ۲ — توکن‌های طراحی ادمین در برابر user-app
| توکن | henza_admin | apps/user-app (ساخته‌شده) |
|---|---|---|
| secondary | `#836447` | `#8D6A46` |
| فونت | `p-yek` (Iran Yekan) | `yekan_bakh` |
**پیشنهاد من:** admin-panel توکن‌های خودِ ادمین را بگیرد (`#836447` + `p-yek`) تا وفادار بماند؛ user-app دست نخورد. دو اپ جدا هستند و لازم نیست یکی شوند.
---
## ساختار فازها
### Phase 0 — پایه‌ی `apps/admin-panel`
- پاک‌سازی scaffold فعلی: **۱۱ artifact بیلد** (`.js` / `.d.ts` کنار `.ts`) + `tsconfig.tsbuildinfo` حذف می‌شوند؛ ۳ view انگلیسی موجود (`DashboardView`, `OrderDetailView`, `OrderNewView`) کنار می‌روند چون معادل legacy دارند.
- Vite 5 + Vue 3 + Pinia + ant-design-vue 3 + Less با `modifyVars`.
- پورت `assets/scss/ara/ant.less` (426 خط) و `farsi.scss`، فونت‌های `p-yek`، `rtl: true`.
- `isBranch()` → `import.meta.env.MODE === 'branch'` + یک script جدا در `package.json` برای حالت شعبه.
- **checkpoint:** dev server بالا می‌آید، صفحه‌ی خالی با تم و فونت درست رندر می‌شود.
### Phase 1 — احراز هویت و ACL در بک‌اند
شکاف اصلی: `AdminUser.role` یک enum تک‌مقداری است و مدل `permission(rid, ut)` را نمی‌تواند نمایندگی کند.
- افزودن به Prisma: `UserType` و `Permission { rid, userTypeId }` با primary key مرکب — عیناً مطابق مهاجرت `2021_09_18_115342_permissions_table.php`.
- `AdminUser.userTypeId` اضافه می‌شود (enum فعلی برای سازگاری فعلاً می‌ماند).
- endpoint ها: `POST /admin/auth/login`، `GET /admin/users/show` (که مثل `UserController::showData` لیست `rid` را برمی‌گرداند)، JWT + guard.
- seed برای ۵۸ rid.
- **checkpoint:** لاگین واقعی با curl، و `users/show` لیست permission برمی‌گرداند.
### Phase 2 — لایه‌های مشترک فرانت
- axios client + **لایه‌ی نگاشت** (تصمیم شما: REST تمیز در بک‌اند + mapping در فرانت) — این لایه‌ی نگاشت جایی است که ۱۱۴ آدرس بی‌قاعده‌ی legacy (`/const/__list__ut`، `delete/product/:id` با POST، `get/variants` با POST، `single/order-sts/edit`) به endpoint های تمیز جدید ترجمه می‌شوند.
- Pinia: `auth`, `users` (شامل **`checkParent()` عیناً**), layout.
- روتر + گارد برای هر دو حالت. **دو باگ legacy را عیناً پورت نمی‌کنم چون در Vue Router 4 اپ را قفل می‌کنند** — `router.push` بدون `next()` (خط ۱۹۸) و `roles` که هنگام logout باطل نمی‌شود (خط ۱۶۲). این‌ها را در گزارش باگ فهرست می‌کنم؛ رفتار کاربر یکسان می‌ماند.
- `navMenuItems.js` (611 خط: `adminMenu` + `branchMenu`) به‌صورت داده پورت می‌شود.
- Layout ها: `Main.vue` (281 خط)، `FullPage.vue`، منوی عمودی و افقی، navbar.
- یک XTable + فیلترها + ۱۸ schema ستون.
- کامپوننت‌های مشترک: `DateInput` (جلالی)، `QuerySelector`، `TooltipBtn`، آپلودها، `ZoomableImage`، `goldPrice`، `Moshtarian`، `ImportExcel`.
- **checkpoint:** لاگین می‌شوید، منو با permission های واقعی فیلتر می‌شود، یک جدول خالی رندر می‌شود.
### Phase 3+ — Batch های view (هر کدام با endpoint خودش)
| Batch | محتوا | view | LOC تقریبی |
|---|---|---|---|
| 3 | سفارش‌ها | `order/{List,create,MakeOrder,OmdeList,TaminList}` | ~1,530 |
| 4 | مشتریان عمده | ۵ لیست عمده + `ReagentList` | ~3,475 |
| 5 | مشتریان تک + نظرات | `AddCustomer`, `single-customer/*`, `Comments` | ~900 |
| 6 | محصولات | `AddProduct` (1,044), `EditProduct`, `ProductList`, `ChangePercent` | ~2,100 |
| 7 | دسته‌ها و کالکشن‌ها | `category/{Add,Edit,List}` | ~900 |
| 8 | اسناد مالی و کیف پول | `finical/*`, `wallet`, `manage/wallet` | ~600 |
| 9 | تخفیف‌ها و promotion | `promotion/*`, `offersIgnore` | ~500 |
| 10 | کاربران و دسترسی‌ها | `user/{ListUser,UserCreate,UserEdit,Permission,myData}` | ~700 |
| 11 | شعبه‌ها و کارگاه‌ها | `branch/{Add,List}`, `workshop/{Add,List}` | ~500 |
| 12 | فاکتورها | `invoice/{Add,Edit,List}` | ~800 |
| 13 | متفرقه | `city/*`, `sms`, `notify`, `newsletters`, `statics`, `manage/{Const,report}` | ~2,200 |
| 14 | داشبوردها | `DashboardECommerce` (499), `DashboardAnalytics` (342) | ~840 |
| 15 | حالت شعبه | `branch/{login,dashboard,addCustomer,buyList,order/create}` | ~900 |
هر Batch: endpoint های NestJS → view ها → checkpoint (بیلد + `vue-tsc` + fetch زنده‌ی API + مقایسه‌ی چشمی با legacy).
### Phase 16 — ممیزی نهایی
گزارش کمّی: view های رندرشده، endpoint های پاسخ‌ده، rid های پوشش‌داده‌شده، و **فهرست کامل باگ‌های legacy که وفادار پورت شدند** (تا اینجا ۴ مورد شناسایی شده: دو باگ روتر بالا، `token`/`user` که در `moduleAuth.js` تابع‌اند نه مقدار، و reset فونت در `farsi.scss` که وزن هدینگ‌ها را صاف می‌کند).
---
## چیزی که پورت نمی‌شود (و چرا)
- ۴۸۹ فایل دموی قالب Vuexy — تصمیم خودتان.
- ۸ فایل روتر مرده (`apps.js`, `category.js`, `customer.js`, `dashboard.js`, `full.js`, `manage.js`, `order.js`, `product.js`, `temp.js`) — ۰ importer دارند.
- ۴ ماژول Vuex ثبت‌نشده (`todo`, `calendar`, `chat`, `email`) — در خود legacy comment شده‌اند.
---
# گزارش باگ‌های legacy (به‌روزرسانی زنده)
این‌ها **وفادار پورت شده‌اند** یا برای پورت وفادار علامت خورده‌اند — هیچ‌کدام بی‌صدا اصلاح نشده. طبق قاعده‌ی شماره‌ی ۲.
| # | باگ | محل | وضعیت در پورت |
|---|---|---|---|
| 1 | `router.push` بدون `next()` داخل `beforeEach` | `router.js:198` | در Vue Router 4 روتر را قفل می‌کند. Phase 2 با `next({path:'/auth/login'})` معادل‌سازی می‌شود؛ رفتار کاربر یکسان. |
| 2 | `roles` در module-scope که هنگام logout باطل نمی‌شود | `router.js:162,190` | کاربر بعدی دسترسی‌های کاربر قبلی را می‌گیرد. Phase 2 داخل store نگه داشته و روی logout پاک می‌شود. |
| 3 | `token` و `user` در state به‌جای مقدار، **تابع** تعریف شده‌اند | `store/auth/moduleAuth.js` | `store.state.auth.token` همیشه truthy است، پس شرط `isLogin` در `getPermission()` بی‌اثر است. وفادار پورت می‌شود. |
| 4 | هر ۵ بلاک `@font-face` به همان `iran-yekan-400.woff2` اشاره می‌کنند | `assets/scss/ara/farsi.scss` | فایل `iran-yekan-700.woff2` وجود دارد ولی هرگز بارگذاری نمی‌شود؛ متن bold با وزن معمولی رندر می‌شود. **اصلاح شد (۲۰۲۶-۰۹-۱۴، طبق درخواست کاربر): وزن bold/700 اکنون به `iran-yekan-700.woff2` اشاره می‌کند.** |
| 5 | `format("woff")` برای فایل `.woff2` | `farsi.scss` | راهنمای فرمت غلط. **اصلاح شد: `format("woff2")` صحیح شد.** |
| 6 | `font-weight: inherit` در ریست `body, *` | `farsi.scss` | وزن پیش‌فرض همه‌ی هدینگ‌ها (h1..h6) صاف می‌شود. **اصلاح شد: `font-weight: inherit` حذف شد؛ وزن پیش‌فرض هدینگ‌ها بازگشت.** |
| 7 | منو `permissionId:"newsletters.newsletters"` دارد ولی route اش `role: 'newsletters'` است — و آن هم **کامنت شده** | `navMenuItems.js:481` / `router/admin.js:851` | هیچ‌وقت با هم مچ نمی‌شوند؛ آیتم خبرنامه برای همه پنهان می‌ماند مگر rid دقیقاً `newsletters.newsletters` داده شود. |
| 8 | منو `permissionId:"wallet.list"` دارد ولی route اش `role: 'wallet'` است | `navMenuItems.js:262` / `router/admin.js:430` | `checkParent` فقط والد می‌سازد نه فرزند، پس `wallet` هرگز `wallet.list` را تولید نمی‌کند. آیتم منو پنهان می‌ماند در حالی که خود صفحه باز می‌شود. |
| 9 | `secondary-color` و `third-color` در `modifyVars` متغیر واقعی ant نیستند | `vue.config.js` | بی‌اثرند (در خود legacy هم بی‌اثر بودند). راستی‌آزمایی شد: در `default.less` وجود ندارند. **رفتار یکسان حفظ شد.** |
## یافته‌ی مهم درباره‌ی ACL
`checkParent()` از ۵۸ rid دانه‌درشتِ روترها، **۸۰ rid** می‌سازد (فقط والدها). از ۱۹ rid که تنها در منو هستند، ۱۶ تا دقیقاً توسط همین مکانیزم تولید می‌شوند — یعنی درست کار می‌کند. ۳ تای باقی‌مانده: دو مورد باگ واقعی (ردیف ۷ و ۸ بالا) و یکی (`persons.list.client.`) کامنت‌شده و غیرفعال است.
**پس seed دسترسی‌ها باید ۵۸ rid پایه باشد، نه ۸۰** — بقیه در زمان اجرا توسط `checkParent()` ساخته می‌شوند.
---
# وضعیت اجرا
## Phase 0 — پایه‌ی apps/admin-panel ✅
Vite 5 + Vue 3 + Pinia + ant-design-vue 3 + Less. پاک‌سازی ۱۷ artifact بیلد و ۳ view انگلیسی.
`modifyVars` عیناً از `vue.config.js`؛ `ant.less` بایت‌به‌بایت (MD5 یکسان)؛ فونت‌های yekan؛ `isBranch()` روی `import.meta.env.MODE`.
**راستی‌آزمایی:** `vue-tsc` تمیز · بیلد ادمین و شعبه موفق · `#1c2d56` در ۱۵۱ قاعده‌ی CSS · هر ۱۰ ماژول با content-type درست · بیلد ادمین `vertical` و بیلد شعبه `horizontal`.
## Phase 1 — احراز هویت و ACL در بک‌اند ✅
**Prisma:** مدل‌های `UserType` و `Permission` با کلید اصلی مرکب `(rid, userTypeId)` — منطبق با `primary(['rid','ut'])` legacy. `AdminUser.userTypeId` اضافه شد. در دیتابیس واقعی راستی‌آزمایی شد.
**NestJS:** ماژول `auth` با `POST /api/admin/auth/login` (معادل `admin-login`) و `GET /api/admin/auth/me` (معادل `users/show`)، JWT، `JwtAuthGuard` سراسری با `@Public()`، و `PermissionsGuard` با `@RequirePermission(rid)`.
**`expandWithParents()`** = پورت دقیق `checkParent()` در بک‌اند، ولی pure (بدون متغیر module-scope که در legacy باگ داشت).
**seed:** ۵ سطح دسترسی (۸۵ مدیر ارشد با ۵۸ rid، ۸۶ کارشناس فروش، ۸۷ حسابدار، ۸۸ سرپرست کارگاه، ۸۹ کاربر شعبه)، ۹۰ ردیف permission، هر ۵ ادمین متصل. رمز توسعه: `admin1234`.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لاگین درست | 200، JWT واقعی، ۵۸ rid، `ut=85` |
| رمز اشتباه | 401 |
| شماره‌ی ناموجود | 401 (بدون افشای شماره) |
| فیلد ناقص | 400 |
| `/me` بدون توکن | 401 |
| `/me` با توکن نامعتبر | 401 |
| `/me` با توکن معتبر | نام + `user_type` + ۵۸ rid |
| گسترش والدها (کارشناس فروش) | ۱۲ → ۱۸ rid؛ والدهای درست تولید شد |
| ۱۴ endpoint قبلی user-app | همچنان 200 بدون توکن |
## انحراف عمدی از legacy (امنیتی، نه رفتاری)
legacy برای شماره‌ی ناموجود **404** و برای رمز غلط **422** برمی‌گرداند — یعنی هر کسی می‌توانست شماره‌ی ادمین‌های واقعی را حدس بزند. اینجا هر دو **401** شده‌اند. رفتار کاربر عادی تغییری نمی‌کند.
## نکته درباره‌ی ۱۴ endpoint موجود
با سراسری‌شدن guard، همه‌ی آن‌ها با `@Public()` علامت خوردند تا **دقیقاً** رفتار فعلی حفظ شود (user-app نشکند). این‌ها از قبل بدون احراز هویت بودند؛ این یک شکاف موجود است نه چیزی که Phase 1 ساخته باشد. بستنش خارج از دامنه‌ی پورت ادمین است و جدا باید تصمیم‌گیری شود.
## Phase 2 — لایه‌های مشترک فرانت ✅
**تصحیح عدد دامنه:** شمارش قبلی (۵۲ view) اشتباه بود — grep فقط import های تک‌خطی با پسوند `.vue` را می‌دید. شمارش برنامه‌ای و چندخطی نشان می‌دهد روترهای زنده **۶۹ view یکتا** با **۱۸٬۷۲۸ خط** ارجاع می‌دهند و هر ۶۹ فایل در legacy موجودند. این با برآورد اولیه‌ی «~۱۸٬۷۰۰ خط» می‌خواند.
**پورت مکانیکی (نه دستی).** سه دارایی پرخطر با اسکریپت تبدیل شدند تا متن فارسی و ساختار دقیقاً حفظ شود:
| فایل | راستی‌آزمایی |
|---|---|
| `navMenuItems.ts` | شمارش هر ۷ فیلد یکسان (permissionId ۷۶، i18n ۷۸، url ۷۶، slug ۵۳، icon ۳۳، submenu ۱۹، header ۲) و **هر ۷۸ برچسب فارسی بایت‌به‌بایت یکسان** (diff خالی) |
| `router/admin.ts` | path ۷۱، name ۴۶، role ۶۵، pageTitle ۳۵، breadcrumb ۳۲ — همه یکسان |
| `router/branch.ts` | path ۱۷، role ۱۴ — یکسان |
**ساخته شد:** `stores/{auth,users,layout}.ts`، `api/{client,endpoints}.ts`، `router/index.ts` با گارد پورت‌شده، `layouts/{main/Main,full-page/FullPage,components/{VerticalNavMenu,TheNavbar}}.vue`، `views/auth/Login.vue`، ۴ صفحه‌ی خطا، و `_NotPorted.vue`.
**`_NotPorted.vue`:** هر ۶۸ view باقی‌مانده مسیر و ACL شان از همین حالا زنده است و صفحه‌ای می‌بینید که نام فایل legacy مرجع را نشان می‌دهد. با آمدن هر batch، فایل واقعی جایش را می‌گیرد — بدون دست زدن به روتر.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| `vue-tsc` | تمیز |
| بیلد | موفق، با code-splitting درست (Main/Login/FullPage/error ها chunk جدا) |
| لاگین از طریق proxy ویت | 200، ۵۸ rid |
| لایه‌ی نگاشت | ۷/۷ (شامل نرمال‌سازی اسلش و حفظ query) |
| فیلتر منو: مدیر ارشد | ۵۸ rid → ۸۰ | منو: ۱۶ گروه، ۴۷ آیتم |
| فیلتر منو: کارشناس فروش | ۱۲ → ۱۸ | منو: ۴ گروه، ۱۴ آیتم |
| فیلتر منو: حسابدار | ۱۱ → ۱۷ | منو: ۵ گروه، ۹ آیتم |
| فیلتر منو: بدون دسترسی | ۰ آیتم |
| سرو شدن ۱۱ ماژول | همه با content-type درست |
**تأیید یک رفتار legacy:** با دادن **فقط** `persons`، دقیقاً دو آیتمی که `permissionId` شان در legacy کامنت شده (لیست بنکداران، لیست مغازه داران حضوری) دیده می‌شوند — چون قاعده‌ی legacy این است که گره‌ی بدون `permissionId` عمومی است، ولی والدِ دارای مجوز همچنان آن‌ها را پنهان می‌کند. عیناً حفظ شد.
**دو باگ مسدودکننده‌ی legacy که در گارد اصلاح شدند** (بدون تغییر رفتار کاربر): `router.push` بدون `next()` → بازگرداندن redirect؛ و `roles` ماژول‌سطح → کش در Pinia که `logout()` پاکش می‌کند.
**اضافه‌ی خارج از legacy:** interceptor پاسخ برای 401. legacy هیچ interceptor پاسخی نداشت، پس توکن منقضی باعث می‌شد همه‌ی درخواست‌ها بی‌صدا شکست بخورند بدون راه بازگشت به لاگین.
## Batch 3 — سفارش‌ها + XTable ✅ (جزئی)
**بک‌اند:** ماژول `admin-table` (قرارداد XTable → Prisma) و `admin-orders` با ۴ route:
`POST admin/orders/list`، `GET admin/orders/:id`، `POST admin/orders/status`، `GET admin/orders/status-options`.
**فرانت:** `components/x-table/{XTable.vue, model.ts}` (یک نسخه، نه ۴ تا — طبق تصمیم شما)، `TooltipBtn.vue`، `utils/filters.ts`، `views/order/{List.vue, orderListModel.ts}`.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لیست سفارش (قرارداد XTable) | ۳ ردیف، ساختار `{rows, query}` درست |
| بدون توکن | 401 |
| حسابدار (بدون `order.list`) | **403** ← گارد دسترسی کار می‌کند |
| فیلتر رابطه‌ای `customer.fullName ~ علی` | ۱ ردیف |
| جستجوی آزاد «علی» / «zzzz» | ۱ / ۰ |
| فیلتر enum، فیلتر تاریخ between، مرتب‌سازی ±createdAt | درست |
| فیلتر روی فیلد غیرمجاز (`passwordHash`) | **400** ← whitelist کار می‌کند |
| خروجی excel | 400 با پیام صریح (نه url مرده) |
| جدول زمانی وضعیت | ۶ مرحله، علامت‌گذاری درست |
| `priceUnit` در برابر legacy | ۹/۹ یکسان (شامل `priceUnit(0)`) |
| بیلد + `vue-tsc` + `tsc` | تمیز |
**یک باگ واقعی که پیدا و اصلاح شد:** اسکوپ شعبه را اولش بر اساس «آیا کاربر `branchId` دارد» گذاشته بودم. چون مدیر ارشد هم شعبه‌ی خانگی دارد، نتیجه این شد که دفتر مرکزی فقط ۱ سفارش از ۳ تا را می‌دید. حالا بر اساس **دسترسی** تصمیم گرفته می‌شود (`buy.order.list` بدون `order.omde` = کاربر شعبه). راستی‌آزمایی شد: ۳/۳.
**یک درس روش‌شناختی:** فیلتر فارسی اول ۰ ردیف برمی‌گرداند. علتش کد نبود — `curl` متن فارسی را به `???` تبدیل می‌کرد. با ارسال از Node درست کار کرد. **ابزار تست هم می‌تواند دروغ بگوید.**
### باگ‌های جدید legacy (ادامه‌ی جدول)
| # | باگ | محل | وضعیت |
|---|---|---|---|
| 10 | `hideSearch()` به‌جای فلگ جستجو، `_hidePageination` را ست می‌کند | `x-table/model/XTbl.js:51-54` | صفربار فراخوانی شده، پس اصلاحش رفتاری را عوض نمی‌کند. اصلاح شد. |
| 11 | حذف وضعیت «تحویل به نماینده» بدهی ثبت‌شده در دفتر کل را پاک نمی‌کرد | `single/order-sts/edit` با `remove:true` | **عیناً پورت نشد.** این مسیر داده را خراب می‌کند (مشتری بدهکار می‌ماند برای سفارشی که دیگر تحویل‌شده نیست). حالا با پیام فارسی رد می‌شود. |
### آنچه در Batch 3 پیاده نشد (صادقانه)
- **۴ view دیگر سفارش‌ها** (`create`, `MakeOrder`, `OmdeList`, `TaminList`) — فقط `List.vue` آمد.
- **خروجی excel/json/print** — نیازمند endpoint ی که فایل بسازد؛ فعلاً 400 صریح می‌دهد نه دکمه‌ی مرده.
- **خروجی PDF سفارش** — legacy مستقیم به بک‌اند PHP وصل بود.
- **ویرایش سفارش** — در خود legacy هم فراخوانی PUT اش کامنت شده بود؛ عیناً پورت شد (فقط UI toggle).
## Batch 3 — تکمیل شد (۴ از ۵ view) ✅
**view های اضافه‌شده:** `OmdeList.vue` (لیست عمده)، `TaminList.vue` (لیست تامین)، `MakeOrder.vue` (سفارش‌های ساختنی) + مدل‌های جدولشان.
**بک‌اند اضافه‌شده:** `admin-preorders` (۵ route برای PreOrder)، `admin-workshops` (لیست)، `admin-cities` (لیست)، و ۳ route جدید روی `admin-orders`: `wholesale/list`، `tamin/list`، `delete`.
**store اضافه‌شده:** `stores/cart.ts` — پورت کامل `store/cart/cart.js` با هر دو گارد موجودی legacy.
**راستی‌آزمایی اجراشده:**
| endpoint | نتیجه |
|---|---|
| `admin/orders/list` | ۳ ردیف |
| `admin/orders/wholesale/list` | ۳ ردیف |
| `admin/orders/tamin/list` | **۲ ردیف** (فقط سفارش‌هایی که به SOURCING رسیده‌اند — فیلتر واقعاً کار می‌کند) |
| `admin/pre-orders/list` | ۲ ردیف، ارقام فارسی حفظ شد (۱۸، ۱۶) |
| `admin/workshops/list` / `admin/cities/list` | ۱ / ۴ ردیف |
| فیلتر روی `colorName ~ رزگلد` | ۱ ردیف |
| حسابدار روی wholesale و tamin | **403** |
| بیلد + `vue-tsc` + `tsc` | تمیز؛ هر ۴ view chunk جدا |
### دو تصحیح مهم روی کار قبلی خودم
**۱. اسکوپ شعبه را اشتباه «اصلاح» کرده بودم.** در گزارش قبلی گفتم منطق legacy غلط است و بر اساس دسترسی بازنویسی‌اش کردم. **اشتباه بود.** `AdminController::getSingleOrders` دقیقاً می‌گوید:
```php
if (!is_null(auth()->user()->branch_id)) {
$records->where('branch_id', auth()->user()->branch_id);
}
```
یعنی هر کاربری که `branch_id` دارد فقط شعبه‌ی خودش را می‌بیند. حالا عیناً همین پیاده شده. مشکل واقعی در **seed** بود که به مدیر ارشد شعبه داده بود؛ دفتر مرکزی باید `branchId: null` داشته باشد. seed اصلاح شد.
راستی‌آزمایی: دفتر مرکزی ۳ سفارش، کاربر شعبه ۱ سفارش.
**۲. `filterSelect` را با امضای غلط پورت کرده بودم.** legacy یک **object map** می‌گیرد (`{0:'معلق', 1:'ثبت سفارش'}`) و در `filterItems` نگه می‌دارد؛ من آرایه گرفته بودم. با مدل‌های Omde/Tamin می‌شکست. اصلاح شد و XTable حالا برای این ستون‌ها dropdown و برای ستون تاریخ date-picker رندر می‌کند.
### یافته‌ی معماری مهم — نیاز به تصمیم شما
legacy سفارش‌ها را در **دو جدول جدا** نگه می‌داشت:
- `orders_orders` (مدل `SingleCustomerOrders`) → لیست تک‌فروشی
- `orders` (مدل `Order`) → لیست عمده
schema جدید **یک مدل `Order`** دارد.
> **✅ بسته شد.** `Order.channel` (`RETAIL | WHOLESALE`) اضافه شد و هر لیست روی آن فیلتر می‌کند.
> آنچه قبلاً اینجا نوشته بودم («در عمل رفتار legacy هم بود چون فیلتر نوعی نداشت») **دقیق نبود**:
> هیچ‌کدام از دو کنترلر فیلتر نداشتند چون روی **دو جدول جدا** کار می‌کردند
> (`orders` و `orders_orders`)، پس دو لیست هرگز هم‌پوشانی نداشتند. جزئیات در انتهای سند.
### باگ legacy جدید
| # | باگ | محل | وضعیت |
|---|---|---|---|
| 12 | حذف سفارش عمده، اسناد حسابداری‌اش را یتیم می‌کرد | `delete-order` | **عیناً پورت نشد** — اگر سند ثبت شده باشد با پیام فارسی رد می‌شود. |
| 13 | حذف سفارش ساختنیِ تبدیل‌شده، پیوند حسابداری را می‌شکست | `custom-order/{id}` | همان رویکرد. |
### باقی‌مانده‌ی Batch 3
`create.vue` (۴۵۲ خط — ثبت سفارش) پورت **نشد**: به گرید محصولات، انتخاب تنوع، و منطق promotion وابسته است که در Batch 6 می‌آید. `stores/cart.ts` که پیش‌نیازش بود آماده است. منطقی‌تر است همراه Batch 6 بیاید.
## Batch 4 — مشتریان عمده ✅
**تصمیم «کامپوننت مشترک» اجرا شد و نتیجه‌اش قابل اندازه‌گیری است:**
| | legacy | پورت‌شده |
|---|---|---|
| ۵ لیست عمده | **۳٬۰۷۲ خط** | **۵۷۲ خط** (کامپوننت مشترک + ۵ wrapper + مدل) |
| اندازه‌ی chunk هر wrapper | — | ۰٫۳۷ تا ۰٫۴۲ کیلوبایت |
| رفتار و ظاهر | — | یکسان |
**اثبات تکراری بودن (با diff، نه حدس):**
- `ShopkeeperList.vue` در برابر `OmdeList.vue`: **دقیقاً ۴ خط** تفاوت (فقط عنوان کارت + import مدل)
- `BankerList` ۱۸ خط، `OmdeArchivedList` ۲۴ خط، `OmdeNonVerifiedList` ۳۴ خط — بقیه‌اش whitespace و ترتیب متدها
- ۵ فایل مدل: **دقیقاً یک خط** تفاوت (url)
**ساخته شد:**
- بک‌اند: ماژول `admin-customers` با ۱۲ route — ۵ لیست عمده + reagent + toggle-active/sts + verify + dashboard + archive + credit
- فرانت: `OmdeCustomerList.vue` (مشترک)، `omdeListModel.ts` (کارخانه)، ۵ wrapper نازک، و `ReagentList.vue` + مدلش
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| ۵ لیست عمده + reagent | همه 200؛ Omde/Banker/Shopkeeper ۶ ردیف، NonVerified **۲**، Archived **۰**، Reagent **۲** |
| نام فیلدهای legacy در پاسخ | `name, gram_level, remain, rial_remain, sts_verify_step_one/two, caj, uaj, active` — همه موجود |
| جریان تایید دو مرحله‌ای | تایید مرحله‌ی یک → `one=2` (که مرحله‌ی دو را باز می‌کند) → تایید مرحله‌ی دو → `two=2` ✓ |
| toggle-active رفت و برگشت | `false` → `true` ✓ |
| dashboard | سفارش‌ها، سفارش باز، اسناد، سطح اعتبار ✓ |
| فیلتر با نام‌های legacy (`name`, `active`, `gram_level`) | همه درست ترجمه شدند |
| فیلتر روی `passwordHash` | **400** |
| حسابدار روی هر ۳ لیست | **403** |
| **کارشناس فروش** | روی `list` **200**، روی `reagent/list` **403** — چون فقط `persons.list.client.omde` دارد نه `persons.list.sellers` |
آخرین ردیف مهم‌ترین است: یک کاربر واحد روی یک endpoint اجازه دارد و روی دیگری ندارد — یعنی ACL واقعاً دانه‌درشت کار می‌کند نه صرفاً «لاگین شده یا نه».
### باگ/کوییرک legacy که وفادار پورت شد
| # | مورد | محل | وضعیت |
|---|---|---|---|
| 14 | در `OmdeNonVerifiedList` هر دو شاخه‌ی popconfirm مرحله‌ی یک `rejectStepOne` را صدا می‌زنند — یعنی از آن صفحه هرگز نمی‌شود تایید کرد | `OmdeNonVerifiedList.vue:64` | ابتدا عیناً پورت شد؛ **۱۴۰۵/۰۷/۲۲ (2026-10-14) رفع شد** — «تایید» حالا تایید می‌کند |
| 15 | در منوی «حساب کاربری»، آیکون از `getStsIcon` (وضعیت سفارش) گرفته می‌شود ولی رنگش از `getActiveIcon` | `OmdeList.vue:124` | عیناً پورت شد |
| 16 | `ReagentListModel` ستون `name` را دو بار اضافه می‌کند (یکی نمایان، یکی مخفی) | `ReagentListModel.js:10-11` | عیناً پورت شد |
| 17 | tooltip «فعال سازی سفارش سفارش» (کلمه تکراری) | `ReagentList.vue:51` | عیناً پورت شد |
### شکاف‌های schema که باید بدانید
۱. **بنکدار در برابر مغازه‌دار:** legacy این‌ها را با `Customer.type` جدا می‌کرد که در طراحی جدید حذف شده. الان هر دو لیست کامل را برمی‌گردانند.
۲. **بایگانی:** ~~schema جدید ستون جدا ندارد~~ → **✅ `Customer.isArchived` اضافه شد.**
۳. **دو سوییچ مجزا:** ~~هر دو یک فیلد را می‌زنند~~ → **✅ `canPlaceOrders` جدا از `isActive`.**
۴. ستون‌های `phone`, `address`, `store_name`, `zone`, `pasazh_name` → **✅ همه اضافه شدند** (به‌علاوه `memberCode`).
**هر سه مورد بسته شد** — جزئیات و تأیید اجرایی در انتهای سند.
## Batch 5 — مشتریان تک + نظرات ✅
**مدل جدید در Prisma:** `Comment` + enum `CommentStatus`. schema جدید اصلاً مدل نظر نداشت، ولی `Comments.vue` یکی از view های این batch بود. کدهای عددی legacy (۱=در انتظار، ۲=تایید، ۳=رد) روی سیم حفظ شدند تا view پورت‌شده بدون تغییر کار کند، ولی در دیتابیس enum ذخیره می‌شود.
**ساخته شد:**
- بک‌اند: ماژول `admin-comments` (۶ route) + ۵ route جدید روی `admin-customers` (`single/list`, `create`, `query/reagent`, `wallet/charge`, `PUT :id`)
- فرانت: `Comments.vue` + مدلش، `SingleCustomer.vue` + مدلش، `AddCustomer.vue` + `bulkCustomer.vue`
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لیست مشتریان تک | ۶ ردیف |
| لیست نظرات | ۳ ردیف با کدهای عددی درست (sts=1/2/3 + برچسب فارسی) |
| `query/reagent` | ۲ کارشناس فروش |
| گردش تایید نظر | approve→APPROVED، reject→REJECTED، بازگشت به pending ✓ |
| پاسخ ادمین (threading) | `replyToId` به والد وصل شد و در آرایه‌ی `reply` والد ظاهر شد ✓ |
| فیلتر وضعیت با کد عددی | sts=1→۱، sts=2→۲، sts=3→۱ ردیف |
| حذف پاسخ | `{success:true}` |
| حسابدار | **403** روی هر دو |
| کارشناس فروش | **200** روی نظرات و مشتریان تک، **403** روی `wallet/charge` (چون rid `wallet` ندارد) |
| بیلد + هر دو typecheck | تمیز |
### یافته‌ی مهم درباره‌ی AddCustomer
فایل legacy اش ۲۷۸ خط است ولی **تقریباً همه‌اش کامنت شده**. محتوای زنده فقط یک تب است که `<bulk-customer/>` را در بر می‌گیرد. تب «مشتری تک» و کل فرم درون‌خطی زیرش در خود legacy کامنت‌اند و اینجا هم **زنده نشدند**. فرم واقعی در `bulkCustomer.vue` است (۳۸۵ خط) با فیلدهای: `fname, lname, ncode, password, mobile, store_name, phone, pasazh_name, city_id, mcode, address, reagent_id, percent, gram_level`.
### شکاف‌های schema در این batch
| مورد | وضعیت |
|---|---|
| `store_name`, `pasazh_name`, `mcode`, `address`, `phone` | **✅ بسته شد** — ستون‌ها اضافه و در create/update وصل شدند |
| آپلود تصویر (`MyUpload`) | **✅ بسته شد** — `ImageUploadInput.vue` در دسته‌بندی، اسلایدر و آواتار کارشناس فروش وصل است |
| `DateInput` جلالی | حذف شد چون فیلد متناظر ندارد (جعل نکردم) |
| `wallet/charge` | legacy مبلغ ریالی می‌گرفت؛ `WalletBonus` گرم طلا می‌خواهد و `expiresAt` اجباری دارد — یک‌ساله گذاشتم |
| ایمیل مشتری تک | ستون ندارد، `null` برمی‌گردد |
اگر می‌خواهید این ستون‌ها واقعاً به schema اضافه شوند بگویید — الان ۹ تصمیم schema روی هم جمع شده.
## Batch 6 — محصولات ✅ (۴ از ۵)
**۴ ستون جدید در schema** — این‌ها واقعاً لازم بودند نه تزئینی:
| ستون | چرا |
|---|---|
| `ProductVariant.qty` | گاردِ «موجود نیست» در سبد خرید بدون آن کار نمی‌کند |
| `ProductVariant.isPreOrder` | همان گارد: تنوع پیش‌سفارشی با موجودی صفر هم سفارش‌پذیر است |
| `Product.productCode` | ستون واقعی در گرید محصولات |
| `Product.showType` | فیلتر واقعی (۱ سایت، ۲ گوشی، ۳ هر دو) |
هشدار data-loss پوش را بررسی کردم: تنها موردش unique روی ستون **جدید و nullable** بود و Postgres چند NULL را مجاز می‌داند، پس داده‌ای در خطر نبود. بعد از پوش راستی‌آزمایی شد: هر ۵ محصول سالم.
**ساخته شد:** ماژول `admin-products` (۱۴ route) + `ProductList.vue`، `AddProduct.vue` (هم ایجاد هم ویرایش)، `EditProduct.vue` (wrapper نازک — ۰٫۲۶ کیلوبایت)، `ChangePercent.vue`. به XTable هم قابلیت `hasSelection` + `v-model` اضافه شد که ProductList لازم داشت.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لیست محصولات | ۵ ردیف با `product_code`, `show_type_str`, تصاویر، تعداد تنوع |
| لیست تنوع‌ها | ۹ ردیف |
| برند/دسته/سینی | ۲ / ۵ / ۱ |
| جزئیات (حالت ویرایش) | `body_html`, `cats`, `ciny_ids`, `variants_all` — همه درست |
| ایجاد با تنوع درون‌خطی | ۲ تنوع ذخیره شد با موجودی و پرچم پیش‌سفارش |
| ویرایش (یکی ویرایش، یکی جدید، یکی حذف) | حذف‌شده **غیرفعال** شد نه پاک — چون OrderItem به آن ارجاع دارد |
| گارد حذف محصول | محصولی که در سفارش استفاده شده **400** با پیام فارسی |
| حسابدار | **403** |
| کارشناس فروش | `products/list` **200**، `change-percent` **403**، `create` **403** |
| بیلد + هر دو typecheck | تمیز |
### یک باگ در کار خودم که پیدا و اصلاح شد
در همگام‌سازی تنوع‌ها، `keptIds` را **قبل** از ساختن تنوع‌های جدید می‌ساختم، پس تنوع تازه‌ساخته‌شده در آن لیست نبود و بلافاصله غیرفعال می‌شد. تست نشانش داد (`ج جدید` با `active=false` برگشت). اصلاح شد و دوباره تست شد: حالا ویرایش‌شده فعال، جدید فعال، و فقط حذف‌شده غیرفعال است.
### یک اشتباه در روش تست خودم
برای آزمودن گاردِ حذف، روی محصولات حلقه زدم و delete صدا زدم — که محصولات بدون سفارش را **واقعاً حذف کرد** (۵ تا شد ۱ تا). چون seed idempotent است با یک `db seed` کامل برگشت (۵ محصول، ۹ تنوع، ۳ سفارش، ۳ نظر، ۲ پیش‌سفارش سالم). ولی تست مخرب روی داده‌ی واقعی کار درستی نبود.
### باقی‌مانده‌ی Batch 6
`order/create.vue` (۴۵۲ خط) پورت **نشد**. سبد خرید (`stores/cart.ts`) و گرید محصولات که پیش‌نیازش بودند آماده‌اند، ولی خودش شامل انتخاب تنوع، منطق promotion، محاسبه‌ی قیمت لحظه‌ای طلا و ثبت سفارش است — یعنی تقریباً یک batch مستقل. صادقانه: برای اینکه درست دربیاید باید جدا بهش بپردازم.
### فیلدهایی که رندر نشدند (به‌جای اینکه جمع و بی‌صدا دور ریخته شوند)
`delivery_type`, `message_long_delivery`, `offer_day`, `has_warranty`, `is_exist`, `tags`, دو آپلودر تصویر، و رنگ/اجرت ریالی تنوع — هیچ‌کدام ستون متناظر در schema ندارند.
## Batch 3 و 6 تکمیل شدند — `order/create.vue` ✅
سازنده‌ی سفارش ادمین پورت شد، پس هر دو batch حالا کامل‌اند (۵/۵ و ۵/۵).
**نکته‌ی معماری:** `createFromAdmin` به `OrdersService.createOrder` موجود واگذار می‌کند نه اینکه منطق را دوباره بنویسد. دلیلش این است که فرمول وزن و گیتِ سقف اعتبار آنجا زندگی می‌کنند؛ پیاده‌سازی موازی باعث می‌شد دو مسیر به مرور از هم دور بیفتند.
**یک تصمیم امنیتی:** legacy مقادیر `total_price`, `total_weight`, `gold_price_original` را در **مرورگر** حساب می‌کرد و می‌فرستاد. این‌ها نادیده گرفته می‌شوند و سرور از روی snapshot تنوع‌ها بازمحاسبه می‌کند — وگرنه یک کلاینت دستکاری‌شده می‌توانست بدهی طلا را تغییر دهد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| `order/type` | `{1: ثبت سفارش توسط مسئول فروش, 2: ثبت مشتری}` |
| گرید محصولات شامل `thumb` + `variants_all` | بله (۲ تنوع) |
| `product/variant` (rehydrate سبد) | ۱ محصول با ۲ تنوع |
| ثبت سفارش از طریق builder | `ORD-...` ساخته شد |
| **فرمول وزن** | rash ۸٫۱۳۳ × (۱ + ۲/۱۰۰) × qty ۲ = **۱۶٫۵۹۱۳** — انتظار و واقعیت **دقیقاً یکسان** |
| **گیت سقف اعتبار** | سفارش ۸۲۹ گرمی برای مشتری با سقف ۸۰ → **SUSPENDED** نه rejected (رفتار legacy) ✓ |
| **ثبت بدهی** | بدهی از ۰ به ۸٫۲۹۵۷ رفت، دقیقاً برابر وزن سفارش، فقط یک بار، در `DELIVERED_TO_AGENT` ✓ |
| بیلد + هر دو typecheck | تمیز |
بعد از تست، ۳ سفارش آزمایشی پاک و بدهی صفر شد؛ ۳ سفارش seed و ۵ محصول و ۹ تنوع سالم ماندند.
### کوییرک legacy که وفادار پورت شد
| # | مورد | محل | وضعیت |
|---|---|---|---|
| 18 | `totalWeight` وزن‌ها را جمع می‌زند **بدون ضرب در تعداد** | `create.vue` computed | عیناً حفظ شد (ستون «مجموع» هر ردیف درست ضرب می‌کند، فقط جمع کل اشتباه است) |
| 19 | سقف تعداد وقتی موجودی صفر است `Infinity` می‌شود | `create.vue` qty slot | عیناً حفظ شد — همین است که تنوع پیش‌سفارشی را سفارش‌پذیر نگه می‌دارد |
### ستون‌های قیمت که جعل نشدند
legacy از هر تنوع `price` و `price_with_discount` می‌خواند. کاتالوگ جدید این‌ها را ذخیره نمی‌کند (قیمت طلا لحظه‌ای است و اجرت درصدی). به‌جای ساختن عدد ریالی ساختگی، ستون‌ها وزن و درصد اجرت را نشان می‌دهند.
**وضعیت: ۱۹ از ۶۹ view. Batch های ۳، ۴، ۵، ۶ کامل.**
## Batch 7 — دسته‌ها و کالکشن‌ها ✅
**۵ ستون جدید در Category:** `type` (۱ سایت، ۲ موبایل)، `imageUrl`، `iconUrl`، `metaTag`، `description` — همه ستون یا فیلد واقعی در گرید و فرم legacy بودند.
**ساخته شد:** ماژول `admin-categories` (۱۲ route — شامل کالکشن‌ها/«سینی») + `List.vue`، `Add.vue` (هم ایجاد هم ویرایش)، `Edit.vue` (wrapper نازک — ۰٫۲۲ کیلوبایت)، `CategoryTbl.ts`.
### یک تداخل واقعی در لایه‌ی نگاشت که پیدا و حل شد
`POST category` در legacy **یک endpoint است که دو مصرف‌کننده دارد**: گرید دسته‌بندی‌ها، و picker در فرم محصول که `data.rows` را از همان پاسخ می‌خواند. من قبلاً در Batch 6 یک `admin/products/categories` تکراری ساخته بودم. حذف شد و هر دو روی `admin/categories/list` نشستند. راستی‌آزمایی: پاسخ هم `{rows, query}` دارد (نیاز گرید) و هم `rows[].type_str` (نیاز picker).
همچنین `AddProduct` و `ChangePercent` اصلاح شدند تا مثل legacy عنوان را با پسوند نوع نمایش بسازند: ``x.title + ` (${x.type_str}) ` ``.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لیست دسته‌ها | ۵ ردیف با `type_str`، پدر، تعداد محصول |
| درخت (picker پدر) | ۵ گره |
| لیست کالکشن‌ها | ۱ ردیف: «پرفروش‌های این هفته (۳ محصول)» |
| ساخت پدر + فرزند | `parentId` درست ست شد |
| **گارد حلقه: خودش به‌عنوان پدر** | **400** «یک دسته نمی‌تواند پدر خودش باشد» |
| **گارد حلقه: زیرمجموعه به‌عنوان پدر** | **400** «دسته‌ی انتخاب‌شده زیرمجموعه‌ی همین دسته است» |
| گارد حذف: دسته با زیردسته | **400** با شمارش |
| گارد حذف: دسته با محصول | **400** با شمارش |
| حسابدار و کارشناس فروش | **403** (هیچ‌کدام `collection.list` ندارند) |
| یکپارچگی داده بعد از تست | ۵ دسته، ۵ محصول، ۳ سفارش سالم |
### باگ‌های legacy که عیناً پورت نشدند (داده را خراب می‌کردند)
| # | مورد | وضعیت |
|---|---|---|
| 20 | هیچ گارد حلقه‌ای در انتخاب پدر دسته نبود | اگر دسته‌ای زیرمجموعه‌ی خودش می‌شد، هر رندر بازگشتی درخت قفل می‌کرد. حالا ۴۰۰ می‌دهد. |
| 21 | حذف دسته بدون بررسی وابستگی | محصولات یتیم و زیردسته‌های اشاره‌کننده به پدر ناموجود می‌ساخت. حالا رد می‌شود. |
### کوییرک‌های legacy که عیناً پورت شدند
`CategoryTbl` روی دو ستون متنی (`title`, `parent.title`) `filterNumber()` صدا می‌زند — حفظ شد.
### چیزی که جعل نشد
دو آپلودر تصویر (تصویر شاخص / آیکون) به‌صورت ورودی URL رندر شدند، چون schema آدرس ذخیره می‌کند و این پورت هنوز endpoint آپلود ندارد — یک file picker که کاری نمی‌کند بدتر از یک ورودی صادق است.
**وضعیت: ۲۲ از ۶۹ view. Batch های ۳–۷ کامل.**
## Batch 8 — اسناد مالی و کیف پول ✅
این batch مستقیماً به دفتر کل وصل است، پس بیشترین دقت را خواست.
**یافته‌ی کلیدی:** یک ردیف «cash» در legacy **همان LedgerEntry** است. چهار نوع سند legacy دقیقاً روی enum موجود می‌نشینند:
| کد | عنوان | LedgerSourceType | تبدیل |
|---|---|---|---|
| ۱ | آب شده | `REPAYMENT_MELTED_GOLD` | `value = original_value × ayar / 750` |
| ۲ | حواله | `REPAYMENT_TRANSFER` | گرم بدون تغییر |
| ۳ | چک | `REPAYMENT_CHEQUE` | گرم بدون تغییر |
| ۴ | پولی | `REPAYMENT_CASH` | `value = price / (price_per_gram / 4.331802)` |
**تصمیم معماری:** ثبت سند به `LedgerService.postGoldRepayment` واگذار می‌شود نه اینکه ریاضیات دوباره نوشته شود. همین سرویس صاحب تبدیل عیار و مظنه و قواعد تایید/تراز است.
**مدل‌های جدید:** ۹ ستون متادیتا روی `LedgerEntry` (`labName`, `hallmark`, `accountName`, `bankName`, `transferTo`, `originalValue`, `priceRial`, `receivedAt`) + مدل `Setting` (کلید/مقدار، جایگزین `config_static` — Batch 13 هم لازمش دارد).
**راستی‌آزمایی اجراشده — کل زنجیره‌ی حسابداری:**
| تست | نتیجه |
|---|---|
| فرمول عیار | ۱۰ گرم با عیار ۹۰۰ → **۱۲** دقیقاً |
| فرمول پولی | ۱۰۰٬۰۰۰٬۰۰۰ با مظنه ۳۵٬۰۰۰٬۰۰۰ → **۱۲٫۳۷۶۶** دقیقاً |
| سند تاییدنشده | تراز را **تکان نمی‌دهد** ✓ |
| تایید سند | بدهی دقیقاً به اندازه‌ی وزن نرمال‌شده **کم** شد (۰ → −۱۲) |
| **لغو تایید (toggle)** | بدهی دقیقاً برگشت (−۱۲ → ۰) ✓ |
| متادیتا (آزمایشگاه، انگ، بانک، حساب) | همه ذخیره و بازخوانی شدند |
| حسابدار | **200** روی اسناد و کیف پول (rid دارد) |
| کارشناس فروش | **403** روی هر دو |
| بیلد + هر دو typecheck | تمیز |
بعد از تست همه‌ی اسناد آزمایشی پاک شدند و بدهی مشتری به صفر برگشت.
### دو باگ legacy که عیناً پورت نشدند (داده‌ی حسابداری را خراب می‌کردند)
| # | مورد | وضعیت |
|---|---|---|
| 22 | حذف سند **تاییدشده** | تراز مشتری قبلاً جابه‌جا شده بود؛ حذف آن را بدون رد پا اشتباه می‌گذاشت. حالا ۴۰۰ می‌دهد. |
| 23 | ویرایش **مبلغ** سند تاییدشده | بدهی مشتری را بی‌صدا بازنویسی می‌کرد. حالا ۴۰۰ با پیشنهاد ثبت سند اصلاحی. |
هر دو تست شدند و پیام فارسی می‌دهند.
### کوییرک‌های legacy که عیناً پورت شدند
- `list_model` روی دو ستون عددی (`price`, `value`) `filterDate()` صدا می‌زند
- کارت لیست شارژ کیف پول روی rid **`wallet.percent`** گیت شده نه `wallet.list`
- در `manage/wallet.vue` تب «تاریخ تولد» اول نوشته شده ولی `key="2"` دارد، و تب دوم (`key="1"`) پیش‌فرض باز می‌شود
- مسیر تایید سند در سمت کلاینت املای legacy را نگه داشته: `cash/confirem/{id}`
**وضعیت: ۲۶ از ۶۹ view. Batch های ۳–۸ کامل.**
## Batch 9 — تخفیف‌ها و استثناها ✅
schema جدید **هیچ مفهومی از تخفیف نداشت**. دو مدل اضافه شد:
- `Promotion` با enum های `PromotionScope` (محصول/دسته/مشتری/برند) و `PromotionUsage` (یکبار / تا پایان)
- `PromotionExclusion` — جفت (نوع، آیتم)
**ساخته شد:** ماژول `admin-promotions` (۱۲ route) + `PromotionForm.vue` (مشترک)، دو wrapper نازک، `promotion_list.vue`، `autopromotion.vue`، `offersIgnore-add-edit.vue`، و `brands-table` روی محصولات.
### دوباره الگوی کامپوننت مشترک
`promotion_add_edit.vue` و `promotion_tak_add_edit.vue` با diff بررسی شدند: تنها تفاوت‌های واقعی **عنوان کارت** و اینکه نسخه‌ی «تک» گزینه‌ی «مشتری» و کل بلوک مشتری‌اش را **کامنت** کرده است. هر دو wrapper نازک روی `PromotionForm` شدند با prop `allowCustomerScope`.
### دو یافته که فرضم را اصلاح کردند
**۱. `autopromotion` گزارش نیست، صفحه‌ی تنظیمات است.** اولش آن را به‌صورت خلاصه‌ی تخفیف‌های فعال پیاده کردم. خواندن خود view نشان داد یک سوییچ و یک درصد است برای «تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید». اصلاح شد و در `Setting` ذخیره می‌شود.
**۲. `offersIgnore` فقط «نوع» را ذخیره نمی‌کند.** اولش `PromotionExclusion` را با یک ستون `type` ساختم. خواندن `handleSubmit` نشان داد `{type, item_id: [...]}` می‌فرستد — یعنی آیتم‌های مشخص. schema به جفت (نوع، آیتم) اصلاح شد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| ۴ گزینه‌ی scope و ۲ گزینه‌ی usage | برچسب‌های فارسی درست |
| ساخت تخفیف محصولی | `type_customer=2` (عمده)، فقط `product_id` پر، بقیه null ✓ |
| ساخت تخفیف مشتری | `type_customer=1` (تک) — همین فیلد تعیین می‌کند کدام rid و کدام صفحه‌ی ویرایش |
| درصد ۱۵۰ | **400** |
| تاریخ پایان قبل از شروع | **400** |
| scope نامعتبر | **400** |
| استثناها: ارسال ۳ آیتم سپس ۱ آیتم | ۳ → ۱ ردیف (تیک‌نخورده‌ها حذف شدند ✓) |
| استثنای نوع دیگر | نوع قبلی دست نخورد ✓ |
| حذف کل یک نوع | `{success, removed:1}` |
| کارشناس فروش | **403** روی list و auto |
| بیلد + هر دو typecheck | تمیز |
### یک اشکال محیطی که وقت گرفت
`promotions/list` اول **404** داد در حالی که لاگ نشان می‌داد route ثبت شده. علتش این بود که یک نمونه‌ی قدیمی API هنوز پورت ۳۰۰۰ را گرفته بود و نمونه‌ی جدید با `EADDRINUSE` بالا نیامده بود — یعنی درخواست‌ها به بیلد قدیمی می‌رفتند. با kill کردن همه‌ی پروسه‌های node حل شد. **درس: «route ثبت شده» در لاگ به‌تنهایی یعنی آن نمونه بالا آمده، نه اینکه همان نمونه جواب می‌دهد.**
### کوییرک legacy که حفظ شد
عنوان کارت در `offersIgnore` «تعریف تخفیف» است — همان رشته‌ای که فرم تخفیف استفاده می‌کند — با اینکه این صفحه **استثنا** تعریف می‌کند.
### یک تغییر عمدی
`toEdit` در legacy با `window.open(...)` صفحه‌ی ویرایش را در **تب جدید** باز می‌کرد. اینجا ناوبری درون‌برنامه‌ای شد، چون تب جدید state نشست SPA را از دست می‌دهد.
**وضعیت: ۳۱ از ۶۹ view. Batch های ۳–۹ کامل.**
## Batch 10 — کاربران و دسترسی‌ها ✅
این batch حلقه‌ی ACL را که در Phase 1 باز گذاشته بودیم بست: حالا صفحه‌ی «دسترسی ها» واقعاً جدول `permission(rid, userTypeId)` را مدیریت می‌کند.
### تصحیح مهم روی Phase 1 — `ALL_RIDS` ناقص بود
در Phase 1 فقط **۵۸ rid** را استخراج کرده بودم، از روی `meta.role` در روترها. حسابرسی این batch نشان داد دو نقطه‌ی اعمال دسترسی وجود دارد:
| منبع | تعداد |
|---|---|
| `meta.role` در روترها | ۵۸ |
| `$hasPermission('...')` داخل view ها | ۱۰۰ |
| **اجتماع (واقعاً اعمال‌شده)** | **۱۴۴** |
| کلیدهای موجود در `permissionConst.js` | ۱۷۶ |
یعنی مدیر ارشد seed‌شده **۸۶ دسترسی کم داشت** و نمی‌توانست از عملیات درون‌صفحه‌ای (مثل `order.list.view.edit` یا `comment.state`) استفاده کند. `ALL_RIDS` به‌صورت مکانیکی از هر دو منبع بازتولید شد و seed دوباره اجرا شد. راستی‌آزمایی: مدیر ارشد حالا در لاگین **۱۴۴ rid** می‌گیرد.
### ناسازگاری‌های واقعی بین درخت و کد (حفظ شدند)
- **۳۶ کلید** در درخت هستند ولی هیچ‌جا چک نمی‌شوند (ورودی‌های مرده در UI)
- **۴ rid** اعمال می‌شوند ولی در درخت **نیستند**، پس از UI قابل اعطا نیستند:
`buy.order.list`, `newsletters`, `persons.create.customer.tak`, `persons.list.sellers.view`
هر دو عیناً حفظ شدند و در کامنت فایل ثبت شدند.
**پورت مکانیکی:** `permissionConst.ts` با اسکریپت تبدیل شد — **هر ۱۸۱ برچسب فارسی بایت‌به‌بایت یکسان** (diff خالی)، ۲۲ گروه، ۴ سطح عمق.
**ساخته شد:** ماژول `admin-users` (۱۳ route) + `Permission.vue`، `ListUser.vue`، `UserCreate.vue`، `UserEdit.vue` (wrapper)، `myData.vue`، `userTbl.ts`.
### مهم‌ترین راستی‌آزمایی کل پورت
| مرحله | نتیجه |
|---|---|
| کارشناس فروش روی `products/create` | **403** |
| اعطای `product.create` از طریق ماتریس دسترسی | — |
| همان کاربر، توکن تازه | **200 ✓ دسترسی داده شد** |
| لغو همان rid | **403 ✓ دسترسی گرفته شد** |
یعنی ویرایش ماتریس در UI **بلافاصله رفتار واقعی API را عوض می‌کند** — ACL تزئینی نیست.
**سایر تست‌ها:** ساخت سطح دسترسی جدید (id بعدی: ۹۰) · ساخت کاربر و لاگین موفق با دقیقاً همان rid ها · موبایل تکراری **400** · حذف حساب خودت **400** · حذف کاربری که سند به نامش ثبت شده **400** با پیام فارسی.
### یک تست اشتباه خودم
اول گارد حذف را روی یک ادمین seed‌شده تست کردم و «رد نشد». بررسی نشان داد **گارد درست بود** — همه‌ی ledger های seed شده `postedByAdminUserId: null` دارند، پس چیزی برای گرفتن نبود. ولی در این بین یک ادمین واقعی را حذف کرده بودم؛ با `db seed` برگشت (۵ ادمین). بعد تست را درست انجام دادم: کاربر جدید ساختم، **خودش** یک سند ثبت کرد، و آن‌وقت گارد ۴۰۰ داد و بعد از حذف سند، حذف کاربر موفق شد.
### باگ legacy که اثرش حفظ شد
`myData.vue` مقدار `user.user_type.val` را می‌خواند در حالی که payload عنوان را زیر `title` دارد — یعنی آن سلول برای همه به fallback «آنلاین» می‌افتاد. اینجا عنوان واقعی نشان داده می‌شود با همان fallback.
**وضعیت: ۳۶ از ۶۹ view. Batch های ۳–۱۰ کامل.**
## Batch 11 — شعبه‌ها و کارگاه‌ها ✅
**۵ ستون جدید:** `Branch.address/phone/isActive` و `Workshop.address/phone/supervisorId` — همه ستون یا عملیات واقعی در legacy بودند (`branch/active/{id}` بدون `isActive` کار نمی‌کرد).
**ساخته شد:** ماژول `admin-branches` (۶ route) + گسترش `admin-workshops` (۵ route) + `FacilityList.vue` (مشترک)، `ListBranches/ListWorkshop` (wrapper نازک)، `AddBranches/EditBranches`، `AddWorkshop/EditWorkshop`.
### یک تداخل که پیدا و حل شد
Batch 3 قبلاً `admin/workshops/list` را ساخته بود (برای picker کارگاه در صفحه‌ی سفارش ساختنی). اول داشتم آن را در ماژول جدید تکرار می‌کردم. به‌جایش ماژول موجود را گسترش دادم و منطقش را در `AdminBranchesService` گذاشتم — یک صاحب برای `/admin/workshops`.
### تفاوت واقعی بین دو فرم که حفظ شد
با diff بررسی شد: لیست‌ها فقط **۱۴ خط** فرق دارند (عنوان، مدل، مسیر ویرایش) پس روی کامپوننت مشترک نشستند. ولی فرم‌ها **واقعاً** متفاوت‌اند:
- **شعبه:** مدیر را از بین ادمین‌های موجود انتخاب می‌کند (`user_id`)
- **کارگاه:** سرپرست را **درجا می‌سازد** از سه فیلد (نام/نام خانوادگی/موبایل)
هر دو جداگانه پورت شدند چون ادغامشان رفتار را عوض می‌کرد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| لیست شعب | ۲ ردیف با مدیر و تعداد سفارش |
| لیست کارگاه‌ها | ۱ ردیف |
| ساخت شعبه + انتصاب مدیر | مدیر درست وصل شد |
| toggle وضعیت | `true → false → true` |
| **ساخت کارگاه با سرپرست درجا** | تعداد ادمین ۵ → ۶، سرپرست به‌عنوان `AdminUser` با نقش `WORKSHOP_SUPERVISOR` ساخته شد ✓ |
| round-trip فرم ویرایش | `fullName` درست به سه فیلد شکست |
| سرپرست بدون موبایل | **400** |
| شعبه بدون نام | **400** |
| حسابدار روی شعب | **403** |
| سرپرست کارگاه: کارگاه‌ها **200**، شعب **403** | ✓ |
| بیلد + هر دو typecheck | تمیز |
این بار تست‌ها فقط ردیف‌هایی را پاک کردند که خودشان ساخته بودند (با نام مشخص)، و بعدش یکپارچگی تأیید شد: ۲ شعبه، ۱ کارگاه، ۵ ادمین، حسابدار با `branchId: null`.
### یک نکته‌ی امنیتی عمدی
`admin/workshops/list` **بدون گارد دسترسی** است، چون sazنده‌ی سفارش (Batch 3) از آن به‌عنوان picker استفاده می‌کند و کاربری که `workshop.list` ندارد هم باید بتواند کارگاه انتخاب کند. فقط نام و آدرس کارگاه را برمی‌گرداند. اگر می‌خواهید گیت شود بگویید.
### کوییرک‌های legacy که حفظ شدند
- `ListBranch.js` فلگ `_noFilter` (مفرد) را ست می‌کند که XTable اصلاً نمی‌خواند — نوار فیلتر همچنان دیده می‌شد. حفظ شد.
- `filterNumber()` روی ستون‌های متنی `phone` و `user.name`
- عملیات حذف در هر دو لیست **کامنت شده** است و غایب ماند
**وضعیت: ۴۲ از ۶۹ view. Batch های ۳–۱۱ کامل.**
## Batch 12 — فاکتورها (انتقال به شعبه) ✅
### یافته‌ی مهم درباره‌ی نام
صفحه‌ی «فاکتور» اصلاً فاکتور فروش نیست. endpoint اش `branch/product` است و کاری که می‌کند این است: **تخصیص محصول از دفتر مرکزی به یک شعبه**، همراه با ثبت اینکه چه کسی تخصیص داده. مدل جدید بر اساس کاری که واقعاً می‌کند ساخته شد (`BranchAllocation` + `BranchAllocationItem`) نه بر اساس اسمش.
**ساخته شد:** ماژول `admin-invoices` (۶ route)، `stores/invoiceCart.ts`، `ListInvoice.vue`، `AddInvoice.vue` (هم ایجاد هم ویرایش)، `EditInvoice.vue` (wrapper)، دو مدل جدول. همچنین `min_gram`/`max_gram` به ردیف محصول اضافه شد (سبک‌ترین و سنگین‌ترین تنوع).
### یک باگ legacy که عیناً پورت نشد
`store/cart/cart.js` و `store/invoiceCart/invoiceCart.js` **هر دو** روی کلید `admin_cart_items` می‌نویسند. یعنی ساختن فاکتور، سبد سفارش نیمه‌تمام را پاک می‌کرد و برعکس. راستی‌آزمایی شد (خط ۲۷ هر دو فایل). اینجا سبد فاکتور کلید خودش را دارد (`admin_invoice_items`) — بازتولید از دست رفتن بی‌صدای داده بدتر از یک انحراف مستند بود.
### یک تداخل که پیدا و حل شد
در Batch 11 یک نگاشت `branch/list` اضافه کرده بودم که به گرید شعب اشاره می‌کرد. در این batch دوباره اضافه شد (این بار به picker). typecheck کلید تکراری را گرفت. بررسی نشان داد **تنها مصرف‌کننده‌ی واقعی** `branch/list` فرم فاکتور است و ورودی Batch 11 حدسی و بی‌استفاده بود — حذف شد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| ساخت تخصیص با ۳ محصول | `sum_weight` = ۴٫۰۶۶+۵٫۶+۱٫۹ = **۱۱٫۵۶۶** درست |
| ثبت‌کننده | از JWT گرفته شد، نه از body ✓ |
| snapshot وزن | وزن هر محصول در لحظه‌ی تخصیص ذخیره شد |
| ویرایش (جایگزینی مجموعه) | ۳ → ۱ آیتم |
| بدون شعبه / بدون محصول / محصول ناموجود | هر سه **400** |
| حذف | آیتم‌ها cascade شدند |
| `min_gram`/`max_gram` | درست محاسبه شد (سکه: ۴٫۰۶۶ تا ۸٫۱۳۳) |
| حسابدار **200** / کارشناس فروش **403** | ✓ |
| بیلد + هر دو typecheck | تمیز |
### تصمیم معماری
`EditInvoice.vue` در legacy یک کپی ۳۳۳ خطی از `AddInvoice` است که فقط یک GET اضافه دارد. اینجا `AddInvoice` خودش `:id` را می‌خواند، پس `EditInvoice` یک wrapper نازک شد — همان الگوی Batch های قبل.
**وضعیت: ۴۵ از ۶۹ view. Batch های ۳–۱۲ کامل.**
## Batch 13 — متفرقه (شهرها، پیامک، نوتیفیکیشن، خبرنامه، آمار، ثابت‌ها) ✅
بزرگ‌ترین batch باقی‌مانده: ۹ view، حدود ۲٬۴۰۰ خط.
**۵ مدل جدید + یک enum:** `SmsMessage`، `Notification` + `NotificationRecipient` (وضعیت خواندن هر گیرنده)، `NewsletterSubscriber`، `CustomerActivityLog`، و enum `MessageAudience`. همچنین `City.percent` و `City.percentAux`.
**ساخته شد:** ماژول `admin-misc` (۲۱ route) + ۹ view و ۴ مدل جدول.
### کدهای عددی مخاطب که از خود view استخراج شدند
`sms/create.vue` هر گزینه را با rid جداگانه گیت می‌کند و کدهای عددی خودش را دارد:
| کد | مخاطب | rid |
|---|---|---|
| −۱ | شماره موبایل | `sms.create.single` |
| ۰ | همه مشتریان | `sms.create.all` |
| ۱ | کارشناسان فروش | `sms.create.reagent` |
| ۲ | مشتریان عمده | `sms.create.omde` |
| ۹ | شماره های موبایل | `sms.create.multiple` |
**نکته:** rid به نام `sms.create.tak` وجود دارد ولی **هیچ گزینه‌ای در markup ندارد** — یعنی دست‌نیافتنی است. عیناً حفظ شد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| هر ۵ کد مخاطب پیامک | گیرندگان درست: ۱، ۲، ۶، ۲، ۶ |
| نوتیفیکیشن گروهی | ۶ گیرنده ساخته شد |
| علامت‌گذاری خوانده‌شده | ۰/۶ → **۱/۶** ✓ (وضعیت هر گیرنده جدا) |
| درصدهای شهر (رفت و برگشت) | ۲٫۵ و ۱٫۲۵ درست ذخیره و خوانده شد |
| حذف شهری که مشتری دارد | **400** با شمارش |
| ذخیره و بازخوانی برچسب‌ها | `["طلا","نقره","جواهر"]` ✓ |
| کارشناس فروش روی هر ۳ endpoint | **403** |
| بیلد + هر دو typecheck | تمیز |
همه‌ی داده‌های آزمایشی پاک شدند (۴ شهر seed دست‌نخورده).
### شکاف صادقانه: درگاه پیامک وصل نیست
legacy پیام را به یک سرویس خارجی می‌داد. این پورت اعتبارنامه‌ی چنین سرویسی ندارد، پس پیام **ثبت می‌شود** (با مخاطب و تعداد گیرنده‌ی درست) ولی **ارسال نمی‌شود**. لاگ دقیق است، ولی SMS واقعی نمی‌رود. برای فعال‌سازی باید یک provider انتخاب و تنظیم شود.
### دو تداخل که typecheck گرفت
۱. `branch/list` در Batch 11 حدسی اضافه شده بود (تکراری در Batch 12 کشف شد)
۲. `ciny` در Batch 7 اضافه شده بود و اینجا دوباره — با همان مقدار. حذف شد.
هر دو نشان می‌دهند لایه‌ی نگاشت با ۳۲ ورودی exact و ۲۶ الگو به اندازه‌ای بزرگ شده که تکرار محتمل است. typecheck هر دو را گرفت.
### کوییرک‌های legacy که حفظ شدند
- `CityListModel` ستون `level` دارد ولی payload شهرها چنین فیلدی ندارد — سلول همیشه خالی بود
- `newsletter.js` به `fashion/table` اشاره می‌کند ولی خود view به `/ddf` — دو نام بی‌ربط برای یک صفحه؛ هر دو نگاشت شدند
- عملیات حذف در خبرنامه **کامنت** است و غایب ماند
### تغییر ساختاری عمدی
`Const.vue` در legacy یک کارت ۹۷۷ خطی است با پنج بخش پشت سر هم و بدون راه ناوبری. اینجا به تب تقسیم شد (برندها، برچسب‌ها، اسلایدر، سینی‌ها). هر بخش برچسب‌ها و rid خودش را نگه داشته.
**وضعیت: ۵۴ از ۶۹ view. Batch های ۳–۱۳ کامل.**
## Batch 14 — داشبوردها ✅
**یافته‌ی مهم: بیشتر این دو صفحه در legacy مرده است.**
`DashboardECommerce.vue` (۴۹۹ خط) یک قالب Vuexy است که **همه‌ی کارت‌های انگلیسی‌اش کامنت شده‌اند** — خط به خط بررسی شد:
| کارت | وضعیت |
|---|---|
| Congratulations | کامنت |
| Support Tracker | کامنت |
| Product Orders | کامنت |
| Sales Stats | کامنت |
| Activity Timeline | کامنت |
محتوای زنده فقط این‌هاست: ۳ کارت آمار ۳۰ روزه + «عملکرد کارشناسان فروش» + «میزان فروش» + «سفارشات هفته جاری».
`DashboardAnalytics.vue` (۳۴۲ خط) هم **تنها یک فیلد** از سرور می‌خواند: `model.lastOrder`. بقیه‌اش داده‌ی دموی Vuexy است که داخل خود کامپوننت تعریف شده.
### ApexCharts نصب نشد — و این یک یافته است نه میان‌بر
هر ۳ کارت آمار زنده `:hideChart="true"` پاس می‌دهند (۳ از ۳ راستی‌آزمایی شد) و تنها markup مربوط به `<apexchart>` داخل کارت‌های کامنت‌شده است. کتابخانه فقط **import** شده و هرگز استفاده نمی‌شود. پس نصبش یعنی فرستادن یک کتابخانه که هیچ چیز رندر نمی‌کند.
**ساخته شد:** ماژول `admin-dashboard` (۲ route)، `StatisticsCardLine.vue`، و دو داشبورد.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| داشبورد عمده | ۶ مشتری، ۱۲٫۶۹۸۵ گرم، ۳ سفارش |
| **صحت محاسبات** | مستقل از دیتابیس خام بررسی شد: مجموع وزن سفارش‌های تحویل‌شده = **۱۲٫۶۹۸۵** دقیقاً برابر عدد داشبورد ✓ |
| انتساب به کارشناس فروش | رضا احمدی = ۱۲٫۶۹۸۵ — با بررسی خام یکسان ✓ |
| سفارشات هفته جاری | ۳ ردیف با رنگ وضعیت درست |
| داشبورد تک | فقط `lastOrder` (همان چیزی که view می‌خواند) |
| **حسابدار** | `omde` **200** / `tak` **403** |
| **کارشناس فروش** | `tak` **200** / `omde` **403** |
| بیلد + هر دو typecheck | تمیز |
آخرین دو ردیف آینه‌ی هم‌اند: دو کاربر مختلف، هر کدام روی یکی ۲۰۰ و روی دیگری ۴۰۳ — دقیقاً بر اساس rid خودشان.
### چیزی که جعل نشد
legacy برای هر سفارش `parent`/`children` رندر می‌کرد (سفارش‌های تقسیم‌شده). schema جدید چنین رابطه‌ای ندارد، پس هر دو خالی برمی‌گردند نه ساختگی.
**وضعیت: ۵۶ از ۶۹ view. Batch های ۳–۱۴ کامل.**
## Batch 15 — حالت شعبه + پایان پورت ✅
**۶۹ از ۶۹ view پورت شد. هر دو روتر: ۰ view باقی‌مانده.**
```
admin.ts — ported views wired: 64 | pending: 0
branch.ts — ported views wired: 14 | pending: 0
```
`_NotPorted.vue` (نگهدارنده‌ی جای view های نیامده) حذف شد — دیگر هیچ ارجاعی ندارد.
**این batch ساخت:** `branch/{login, dashboard, addCustomer, buyList, order/create}`، `apps/customer/{singleCustomer, ReagentCustomer, EditBulkCustomer, EditReagent, EditSingleCustomer}`، `customer/CustomerDetail.vue` (مشترک) + دو wrapper، `report/{order_item, product}`، `reg.vue`، و `components/input/DateInput.vue`.
### دوباره الگوی کامپوننت مشترک
`ViewOmdeCustomer` (۳۷۶ خط) و `ViewSingleCustomer` (۴۲۵ خط) هر دو همان چهار پنل را نشان می‌دهند. روی یک `CustomerDetail` نشستند با دو prop: نسخه‌ی عمده لاگ فعالیت دارد، نسخه‌ی تک کیف پول.
### تفاوت واقعی حالت شعبه که حفظ شد
`branch/order/create` اول **مشتری** را می‌پرسد و گرید محصولات را تا انتخاب مشتری پنهان نگه می‌دارد («جهت ثبت سفارش مشتری را انتخاب نمایید»). نسخه‌ی ادمین برعکس است. عیناً پورت شد.
### یک شکاف legacy که عمداً بسته شد
`branch/dashboard.vue` در legacy **هیچ فراخوانی سروری ندارد** (صفر ارجاع axios) — هر دو جدولش روی آرایه‌های دمو داخل خود کامپوننت بسته شده‌اند. اینجا به endpoint های واقعی وصل شد، چون داشبوردی که ردیف ساختگی نشان می‌دهد بدتر از داشبوردی است که چیزی نشان نمی‌دهد.
### DateInput جلالی
legacy از `vue-persian-datetime-picker` استفاده می‌کرد که نسخه‌ی Vue 3 ندارد. جایگزین با ورودی متنی + تبدیل `moment-jalaali` ساخته شد: کاربر جلالی می‌نویسد و می‌خواند (۱۴۰۳/۰۵/۱۲)، ارقام فارسی هم پذیرفته می‌شوند، و مقدار ISO به API می‌رود.
**راستی‌آزمایی اجراشده:**
| تست | نتیجه |
|---|---|
| هر دو روتر | **۰ view باقی‌مانده** |
| بیلد حالت ادمین | موفق |
| **بیلد حالت شعبه** (`--mode branch`) | موفق، و حاوی «فاکتور خرید» (view مخصوص شعبه) ✓ |
| `report/order-item` | ۳ ردیف، مجموع وزن **۱۲٫۶۹۸۵** — همان عددی که داشبورد Batch 14 داد |
| `report/product` | تجمیع درست per-product |
| پنل‌های جزئیات مشتری | سفارش‌ها ۲، اسناد ۴، کیف پول ۰ |
| **فیلتر بازه‌ی تاریخ** | بازه‌ی باز ۳ سفارش، بازه‌ی باریک **۰** ✓ واقعاً فیلتر می‌کند |
| `vue-tsc` و `tsc` | تمیز |
---
# خلاصه‌ی کل پورت
| | عدد |
|---|---|
| view های پورت‌شده | **۶۹ / ۶۹** |
| ماژول‌های بک‌اند NestJS | ۱۶ |
| مدل‌های Prisma اضافه‌شده | ۱۴ |
| rid های ACL | ۱۴۴ |
| ورودی‌های لایه‌ی نگاشت | ۳۴ exact + ۳۵ الگو |
| باگ/کوییرک legacy ثبت‌شده | ۲۳ |
**کاهش تکرار با کامپوننت مشترک (طبق تصمیم شما):**
- ۵ لیست عمده: ۳٬۰۷۲ → ۵۷۲ خط
- ۲ فرم تخفیف، ۲ لیست شعبه/کارگاه، ۲ صفحه‌ی جزئیات مشتری: هر جفت روی یک پیاده‌سازی
- ۷ wrapper نازک Edit عیناً wrapper ماندند (۰٫۲۲ تا ۰٫۲۶ کیلوبایت)
- ۴ fork از XTable → یک نسخه
---
# ممیزی نهایی (Phase 16)
همه‌ی اعداد زیر **اجرا شده‌اند**، نه تخمین. روش هر کدام کنارش آمده.
## ۱. پوشش view — کامل
| سنجه | عدد | روش |
|---|---|---|
| view هایی که روترهای زنده‌ی legacy ارجاع می‌دهند | **۶۹** | استخراج برنامه‌ای از `router/admin.js` + `router/branch.js` |
| پورت‌شده و موجود روی دیسک | **۶۹** | بررسی وجود فایل برای هر کدام |
| باقی‌مانده | **۰** | — |
| ارجاع به `_NotPorted` | **۰** | فایل هم حذف شد |
## ۲. وفاداری متادیتای روتر — بایت‌به‌بایت
| فیلد | legacy | پورت | |
|---|---|---|---|
| `path` (admin) | ۷۱ | ۷۱ | ✓ |
| `name` | ۴۶ | ۴۶ | ✓ |
| `role` | ۶۵ | ۶۵ | ✓ |
| `pageTitle` | ۳۵ | ۳۵ | ✓ |
| `breadcrumb` | ۳۲ | ۳۲ | ✓ |
| `no_scroll` | ۴۱ | ۴۱ | ✓ |
| `path` (branch) | ۱۷ | ۱۷ | ✓ |
| `role` (branch) | ۱۴ | ۱۴ | ✓ |
## ۳. endpoint های زنده
| سنجه | عدد |
|---|---|
| کل route های ثبت‌شده در NestJS | **۱۶۶** |
| از آن، `admin/*` | **۱۵۲** در ۱۶ ماژول |
| route های قدیمی user-app (دست‌نخورده) | ۱۴ |
| **تست دود روی endpoint های خواندنی** | **۵۹/۵۹ پاسخ ۲۰۰، صفر خطا** |
## ۴. پوشش ACL — کامل
| سنجه | عدد |
|---|---|
| واژگان دسترسی legacy (route + view) | **۱۴۴** |
| `ALL_RIDS` (به مدیر ارشد داده می‌شود) | **۱۴۴** |
| rid های legacy که پوشش داده نشده‌اند | **۰** |
| گارد بک‌اند روی rid ناشناخته | **۰** |
| rid هایی که بک‌اند گارد می‌کند | ۶۳ |
| rid هایی که فرانت چک می‌کند | ۷۷ |
## ۵. گاردها واقعاً تبعیض قائل می‌شوند
همان ۵۹ endpoint، چهار کاربر واقعی:
| کاربر | rid | ۲۰۰ | ۴۰۳ | خطا |
|---|---|---|---|---|
| مدیر ارشد (ut ۸۵) | ۱۴۴ | **۵۹** | ۰ | ۰ |
| کارشناس فروش (ut ۸۶) | ۱۲ | ۲۵ | ۳۴ | ۰ |
| حسابدار (ut ۸۷) | ۱۱ | ۱۶ | ۴۳ | ۰ |
| سرپرست کارگاه (ut ۸۸) | ۵ | ۱۲ | ۴۷ | ۰ |
شیب یکنواخت و بدون هیچ خطای دیگری. **این قوی‌ترین شاهد کل پورت است**: دسترسی واقعاً از روی rid محاسبه می‌شود.
## ۶. یکپارچگی داده بعد از ۱۳ batch تست
۲۳ جدول شمرده شد؛ همه‌ی داده‌ی seed سالم: ۴ شهر، ۵ ادمین، ۵ سطح دسترسی، ۱۷۶ permission، ۶ مشتری، ۵ محصول، ۹ تنوع، ۳ سفارش، ۵ سند، ۲ پیش‌سفارش، ۳ نظر.
**بررسی یتیم: صفر** (سفارش بدون مشتری، آیتم بدون سفارش، تنوع بدون محصول).
## ۷. کاهش تکرار (تصمیم «کامپوننت مشترک» شما)
| گروه | legacy | پورت |
|---|---|---|
| ۵ لیست عمده | ۳٬۰۷۲ | ۵۹۰ |
| ۲ صفحه‌ی جزئیات مشتری | ۸۰۱ | ۲۴۵ |
| ۲ فرم تخفیف | ۸۰۳ | ۳۰۴ |
| **جمع این ۹ فایل** | **۴٬۶۷۶** | **۱٬۱۳۹** |
به‌علاوه: ۴ fork از XTable → یک نسخه؛ ۷ wrapper نازک Edit عیناً wrapper ماندند.
## ۸. ابعاد کد
۸۰ فایل `.vue` · ۴۲ فایل `.ts` · ۱۶ ماژول بک‌اند · ۳۴ مدل Prisma · لایه‌ی نگاشت با ۷۷ ورودی دقیق + ۲۶ الگو · ۳۶ باگ/کوییرک legacy ثبت‌شده.
---
## آنچه این ممیزی **نمی‌تواند** تأیید کند
⚠️ **ظاهر رندرشده.** هیچ‌کدام از ۶۹ view چشمی دیده نشده. همه‌ی تأییدها API، بیلد و typecheck بوده‌اند. صفحه‌به‌صفحه باید بررسی شود.
⚠️ **مسیرهای نوشتن با کاربر کم‌دسترسی.** تست دود فقط خواندنی بود تا داده خراب نشود؛ مسیرهای write در batch خودشان تست شدند ولی نه به‌صورت ماتریسی روی همه‌ی نقش‌ها.
⚠️ **درگاه پیامک وصل نیست** — پیام ثبت می‌شود، ارسال نمی‌شود.
## شکاف‌های schema — **هر شش مورد بسته شد**
این بار به‌جای استنتاج از view ها، منطق را مستقیم از سرور legacy
(`henza_server`) خواندم؛ و یک فرضِ غلطِ قبلی را هم اصلاح کرد.
| # | شکاف | چه شد |
|---|---|---|
| ۱ | تفکیک بنکدار/مغازه‌دار | enum `CustomerKind` با **۵** مقدار (`Customer::$TYPES`) |
| ۲ | تفکیک تک/عمده | enum `OrderChannel` (`RETAIL`/`WHOLESALE`) |
| ۳ | ستون بایگانی مجزا | `Customer.isArchived` |
| ۴ | دو سوییچ مجزا | `Customer.canPlaceOrders` جدا از `isActive` |
| ۵ | ستون‌های مشتری | `phone`, `address`, `storeName`, `pasazhName`, `zone`, `memberCode` |
| ۶ | آپلود فایل | مدل `UploadedFile` + `POST admin/files/create` + سرو استاتیک `/uploads/` |
| ۷ | آواتار کارشناس فروش | `Customer.avatarFileId` (رابطه به `UploadedFile`) |
### آنچه از سرور legacy روشن شد
**منبع:** `AdminController::getBanker/getShopkeeper/getCustomer` و
`MajorCustomerController::indexArchive/indexVerify`.
- `banker` → `type=4` · `shopkeeper` → `type=3` (**فقط حضوری**) · لیست اصلی عمده → `type=2`
- **تصحیح یک فرض قبلی:** پیش‌تر نوشته بودم لیست اصلی «بدون فیلتر» است؛ نبود.
- هر سه لیست `is_archive=0` هم دارند، پس بایگانی‌کردن مشتری او را از **همه‌ی** لیست‌ها برمی‌دارد.
- `Customer::$TYPES` یک مقدار پنجم دارد (`۵ سرپرست کارگاه`) که فرم `bulkCustomer.vue` هرگز نشان نمی‌داد. enum هر پنج را دارد، اما فرم همچنان همان چهارتای legacy را نشان می‌دهد.
- در legacy دو جدول جدا بود (`orders` و `orders_orders`)؛ هیچ‌کدام فیلتر نوع نداشتند چون **خود جدول** تفکیک‌کننده بود. `Order.channel` همان نقش را بازی می‌کند.
### دو رفتار legacy که عمداً پورت **نشدند**
۱. **آپلود بدون احراز هویت.** `file/create` در legacy هیچ middleware نداشت و
گروه `auth:api` هم در `routes/admin.php:12` کامنت است — یعنی آپلود ناشناس
می‌پذیرفت. این باگ امنیتی است، نه قابلیت؛ اینجا ادمینِ واردشده لازم است.
rid اختصاصی نگذاشتم چون واژگان legacy ورودی media/file ندارد.
۲. **ارسال پیامک درون `toggleActive`.** legacy هنگام فعال‌سازی مستقیم SMS
می‌فرستاد. انتقالش به ماژول اعلان‌ها ماند (درگاه پیامک هنوز وصل نیست).
### تأیید اجرایی
```
لیست‌ها: عمده(t2)=۱ · بنکدار(t4)=۲ · حضوری(t3)=۲ · بایگانی=۱ · تاییدنشده=۲
سوییچ‌ها: toggle-sts فقط sts را برد · toggle-active فقط active را
بایگانی: ردیف از لیست نوع خارج و به بایگانی رفت، بدون دست‌خوردن active
سفارش: خرده=۳ · عمده=۱
آپلود: ۲۰۰ + فایل از URL خودش بایت‌به‌بایت برگشت · mime بد/حجم زیاد/بدون فایل → ۴۰۰ فارسی · ناشناس → ۴۰۱
ممیزی: ۵۹/۵۹ endpoint = ۲۰۰ · ACL ۱۴۴/۱۴۴ · ۱۵۴ مسیر ادمین
بیلد: api ✓ · admin ✓ · branch ✓ · هر دو typecheck ✓
```
> تست‌های نوشتن فقط روی دو مشتری‌ای انجام شد که خودم ساختم و بعد حذف کردم؛
> ردیف‌های seed دست نخوردند (۶ مشتری، همان‌طور که بود).
### آواتار هم بسته شد ✅
`Customer.avatarFileId` → رابطه‌ی نام‌دار `CustomerAvatar` به `UploadedFile`.
legacy یک **FK** نگه می‌داشت (`image_id`) نه URL، و آن را به شکل تودرتوی
`image: { id, image }` پس می‌داد؛ هر دو نیمه‌ی این قرارداد عیناً حفظ شد، پس فرم
پورت‌شده بدون تغییر کار می‌کند.
| مرحله | نتیجه |
|---|---|
| آپلود | `id` + `url` برگشت |
| ساخت با `image_id` | `avatarFileId` ذخیره شد |
| خواندن | `image: { id, image }` + پیش‌نمایش ۲۰۰ |
| حذف (`image_id: ''`) | هر دو `null` شدند |
| مشتری بدون آواتار | `null` — **نه** خطا |
**یک باگ legacy که عمداً تکرار نشد:** مسیر لود در
`ReagentCustomer.vue:280` بدون محافظ به `data.image.id` دست می‌زد، پس باز کردن
کارشناسی که عکس نداشت throw می‌کرد — و `catch` خودش آن را می‌بلعید و فرم
نیمه‌پر می‌ماند. اینجا خواندن guard شده است.