### تنش ۱ — «بدون دست خوردن» در برابر کد ۹۵٪ تکراری
پنج فایل لیست عمده تقریباً یکساناند:
```
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
**پیشنهاد من:** 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`.
-`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 های تمیز جدید ترجمه میشوند.
- روتر + گارد برای هر دو حالت. **دو باگ legacy را عیناً پورت نمیکنم چون در Vue Router 4 اپ را قفل میکنند** — `router.push` بدون `next()` (خط ۱۹۸) و `roles` که هنگام logout باطل نمیشود (خط ۱۶۲). اینها را در گزارش باگ فهرست میکنم؛ رفتار کاربر یکسان میماند.
-`navMenuItems.js` (611 خط: `adminMenu` + `branchMenu`) بهصورت داده پورت میشود.
| 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` که وزن هدینگها را صاف میکند).
- ۴ ماژول 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` اشاره میکند.** |
| 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؛ والدهای درست تولید شد |
legacy برای شمارهی ناموجود **404** و برای رمز غلط **422** برمیگرداند — یعنی هر کسی میتوانست شمارهی ادمینهای واقعی را حدس بزند. اینجا هر دو **401** شدهاند. رفتار کاربر عادی تغییری نمیکند.
## نکته دربارهی ۱۴ endpoint موجود
با سراسریشدن guard، همهی آنها با `@Public()` علامت خوردند تا **دقیقاً** رفتار فعلی حفظ شود (user-app نشکند). اینها از قبل بدون احراز هویت بودند؛ این یک شکاف موجود است نه چیزی که Phase 1 ساخته باشد. بستنش خارج از دامنهی پورت ادمین است و جدا باید تصمیمگیری شود.
## Phase 2 — لایههای مشترک فرانت ✅
**تصحیح عدد دامنه:** شمارش قبلی (۵۲ view) اشتباه بود — grep فقط import های تکخطی با پسوند `.vue` را میدید. شمارش برنامهای و چندخطی نشان میدهد روترهای زنده **۶۹ view یکتا** با **۱۸٬۷۲۸ خط** ارجاع میدهند و هر ۶۹ فایل در legacy موجودند. این با برآورد اولیهی «~۱۸٬۷۰۰ خط» میخواند.
**پورت مکانیکی (نه دستی).** سه دارایی پرخطر با اسکریپت تبدیل شدند تا متن فارسی و ساختار دقیقاً حفظ شود:
| `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، فایل واقعی جایش را میگیرد — بدون دست زدن به روتر.
**تأیید یک رفتار 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:
**فرانت:**`components/x-table/{XTable.vue, model.ts}` (یک نسخه، نه ۴ تا — طبق تصمیم شما)، `TooltipBtn.vue`، `utils/filters.ts`، `views/order/{List.vue, orderListModel.ts}`.
| فیلتر روی فیلد غیرمجاز (`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).
**بکاند اضافهشده:**`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` دقیقاً میگوید:
یعنی هر کاربری که `branch_id` دارد فقط شعبهی خودش را میبیند. حالا عیناً همین پیاده شده. مشکل واقعی در **seed** بود که به مدیر ارشد شعبه داده بود؛ دفتر مرکزی باید `branchId: null` داشته باشد. seed اصلاح شد.
راستیآزمایی: دفتر مرکزی ۳ سفارش، کاربر شعبه ۱ سفارش.
**۲. `filterSelect` را با امضای غلط پورت کرده بودم.** legacy یک **object map** میگیرد (`{0:'معلق', 1:'ثبت سفارش'}`) و در `filterItems` نگه میدارد؛ من آرایه گرفته بودم. با مدلهای Omde/Tamin میشکست. اصلاح شد و XTable حالا برای این ستونها dropdown و برای ستون تاریخ date-picker رندر میکند.
### یافتهی معماری مهم — نیاز به تصمیم شما
legacy سفارشها را در **دو جدول جدا** نگه میداشت:
> **✅ بسته شد.** `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 و ترتیب متدها
| 14 | در `OmdeNonVerifiedList` هر دو شاخهی popconfirm مرحلهی یک `rejectStepOne` را صدا میزنند — یعنی از آن صفحه هرگز نمیشود تایید کرد | `OmdeNonVerifiedList.vue:64` | ابتدا عیناً پورت شد؛ **۱۴۰۵/۰۷/۲۲ (2026-10-14) رفع شد** — «تایید» حالا تایید میکند |
**هر سه مورد بسته شد** — جزئیات و تأیید اجرایی در انتهای سند.
## 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`)
| کارشناس فروش | **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`, تصاویر، تعداد تنوع |
در همگامسازی تنوعها، `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 تنوعها بازمحاسبه میکند — وگرنه یک کلاینت دستکاریشده میتوانست بدهی طلا را تغییر دهد.
| **گیت سقف اعتبار** | سفارش ۸۲۹ گرمی برای مشتری با سقف ۸۰ → **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` میخواند. کاتالوگ جدید اینها را ذخیره نمیکند (قیمت طلا لحظهای است و اجرت درصدی). بهجای ساختن عدد ریالی ساختگی، ستونها وزن و درصد اجرت را نشان میدهند.
**۵ ستون جدید در 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`، پدر، تعداد محصول |
| یکپارچگی داده بعد از تست | ۵ دسته، ۵ محصول، ۳ سفارش سالم |
### باگهای 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` | گرم بدون تغییر |
**تصمیم معماری:** ثبت سند به `LedgerService.postGoldRepayment` واگذار میشود نه اینکه ریاضیات دوباره نوشته شود. همین سرویس صاحب تبدیل عیار و مظنه و قواعد تایید/تراز است.
- `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 قابل اعطا نیستند:
| اعطای `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` کار نمیکرد).
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 های قبل.
بزرگترین 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 میخواند) |
`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 داد |
⚠️ **ظاهر رندرشده.** هیچکدام از ۶۹ view چشمی دیده نشده. همهی تأییدها API، بیلد و typecheck بودهاند. صفحهبهصفحه باید بررسی شود.
⚠️ **مسیرهای نوشتن با کاربر کمدسترسی.** تست دود فقط خواندنی بود تا داده خراب نشود؛ مسیرهای write در batch خودشان تست شدند ولی نه بهصورت ماتریسی روی همهی نقشها.
- **تصحیح یک فرض قبلی:** پیشتر نوشته بودم لیست اصلی «بدون فیلتر» است؛ نبود.
- هر سه لیست `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
میفرستاد. انتقالش به ماژول اعلانها ماند (درگاه پیامک هنوز وصل نیست).