First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
85 KiB
پلن پورت پنل ادمین — henza_admin → apps/admin-panel
سند برنامهریزی. قبل از تأیید شما هیچ کدی نوشته نمیشود.
قواعد حاکم بر کل کار (تکرار خواستهی شما)
- بدون دست خوردن — منطق، فرمولها، نام فیلدها، متنهای فارسی، ارقام (فارسی/لاتین)، و رفتار RTL دقیقاً مثل legacy.
- باگهای legacy را بیصدا درست نمیکنم — وفادار پورت میکنم و جداگانه به شما گزارش میدهم.
henza_admin/,henza_server/,henza_pwa/فقط خواندنی — هرگز تغییر نمیکنند.- بخش به بخش — هر Batch یک checkpoint دارد؛ بدون تأیید شما به Batch بعدی نمیروم.
- هر 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 |
ابتدا عیناً پورت شد؛ ۱۴۰۵/۰۷/۲۲ (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 شده است.