Files
tehran_tala/docs/ADMIN-PORT-PLAN.md
T
s.esmaeiliandClaude Sonnet 5 f0de478940 admin-panel: fix CSS bugs and dedupe scoped grid utilities
Approved departure from the port's byte-faithful rule (docs/ADMIN-PORT-PLAN.md
bug rows 4-6), fixed and logged rather than silently changed:
- farsi.less: bold/700 @font-face blocks now load iran-yekan-700.woff2
  instead of the 400 file, so bold text actually renders bold. No source
  file exists for weight 300, so that face is dropped rather than faked.
- format("woff") corrected to format("woff2") on all faces.
- Removed 'font-weight: inherit' from the body,* reset, which was flattening
  every heading's weight.

Also deduped 24 view files that each redeclared .grid-2/.grid-3/.grid-4 in
a scoped <style> block with drifted minmax values, though utilities.less
already defines all three globally. Removed the scoped copies; kept
PromotionForm's genuinely different 0.5rem gap as a small .grid-4--tight
modifier instead of a full redeclaration.

Verified: pnpm build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 15:09:11 +03:30

84 KiB
Raw Blame History

پلن پورت پنل ادمین — 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 دقیقاً می‌گوید:

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 عیناً پورت شد پشت prop به نام rejectOnlyStepOne
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 شده است.