دراسة المرحلة الأولى (بحث فقط — صفر كود) · Moon ERP / moonui2 · [FIN] يمسّ حركة المخزون والترحيل المحاسبي
المالك طلب: (أ) النمط المطبَّق على «طلب الشراء» يتطبّق على كل المستندات؛ (ب) نضيف مستندات المخازن لمحرك الاعتماد.
الفحص كشف إن المشكلة أعمق من مجرد «نضيف مستندات»:
ApproveAdjustment.php:47-49 بيحرّك المخزون (يزوّد أو ينقّص) — بدون أي قيد محاسبي وبدون أي توقيع ثانٍ، بصلاحية واحدة فقط. دي القناة الكلاسيكية لسرقة/إخفاء عجز المخزون في أي ERP.
PurchaseGrnController@approve :536 يحرّك المخزون و:539 يُنشئ قيد GR-IR. هو أهم مستند غير موصّل على الإطلاق.
ApproveReceipt::execute() يرفض إلا لو isDraft()، ثم يحرّك المخزون (:141)، ثم يكتب الحالة (:164) — كله في ترانزاكشن واحد. يعني كلمة «اعتماد» في المخازن معناها ترحيل. المستند له حالتان فقط: «لم يحدث شيء» و«المخزون تحرّك».PurchaseRequestController@submitApproval :210 → submitForApprovalIfConfigured()، والحالة تصير PendingApproval في الحالتين. المحرك يتجاهل قيمة الإرجاع عمدًا — وجود سجلات approval_logs (لا الحالة) هو ما يحكم كل شيء بعدها.:238 لو hasApprovalLogs() → approveViaEngine()؛ ولو الرد ليس fully_approved يرجع مبكرًا والمستند يفضل معلّقًا للمستوى التالي.PurchaseOrderController@convertFromRequest :508 → assertApprovedForPost($request, PurchaseRequest). طلب الشراء ليس له مخزون ولا قيد، فالشيء الوحيد الجدير بالحجب هو استهلاكه بواسطة أمر الشراء.ApprovalWorkflowService::submitForApproval :40-50 يُرجِع auto_approved وينشئ صفر سجلات لو مفيش ورك-فلو مطابق. وقتها hasApprovalLogs() = false → الفرع الجديد يُتخطّى، وassertApprovedForPost() عملية لاغية موثَّقة (DrivesApprovalWorkflow.php:88,92-99). هذا هو الثابت الواجب حفظه في كل مستند جديد.| المستند | نقطة التنفيذ (commit) | يحرّك مخزون؟ | يعمل قيد؟ | الواجب حجبه |
|---|---|---|---|---|
| إذن التسليم SalesDeliveryNote | confirm() :303 → ConfirmDeliveryNote | نعم — فقط لوstock_deduction_point='delivery' | لا | confirm() فقط.ship()/deliver() مجرد كتابة حالة — حجبهما لا يفيد. |
| إذن الاستلام PurchaseGRN | approve() :401 | نعم :536 | نعم — GR-IR :539 | approve() |
| استلام مخزني InventoryReceipt | approve() :429 → ApproveReceipt | نعم :141 | غير مباشر (GR-IR) | approve() = ترحيل |
| صرف مخزني InventoryIssue | approve() :225 → ApproveIssue | نعم :195 | نعم — قيد تكلفة المبيعات COGS | approve() = ترحيل |
| تسوية مخزنية InventoryAdjustment | approve() :146 | نعم :47-49 | لا! (ثغرة قائمة) | approve() = ترحيل |
| تحويل مخزني InventoryTransfer | نقطتان: ship() :228 + receive() :251 | نعم (الاثنتان) | لا | ship() — لكن انظر §٦ |
| جرد مخزني InventoryCount | finalize() :232 | لا! | لا | لا شيء — ينشئ «تسوية مسودة» فقط |
| رصيد افتتاحي OpeningBalance | store()/bulk() — ينشئ ويعتمد في نفس الترانزاكشن | نعم | نعم | لا يوجد ما يُحجَب — لا توجد حالة مسودة أصلًا |
للمستندات الثمانية الموصّلة: لا — الحُرّاس موجودة (PurchaseBillController:442, SalesInvoiceController:409, …).
assertApprovedForPost؛ الأمان قائم على أن confirm() يرفض ولا يكتب الحالة، فيظل الأمر draft، وكل المسارات النازلة ترفض المسودّات (SalesDeliveryNoteController:112,167). لو أضاف أحدٌ يومًا حالة PendingApproval إلى OrderStatus، ينهار هذا الحارس بصمت ويتسرّب المخزون. إضافة assertApprovedForPost لهما مجانية — تُنفَّذ في WP1.delivery_note وgrn لهما case في الـenum وليس لهما صف في خريطة attachDocumentReferences (ApprovalWorkflowService.php:382-391 — ٨ صفوف فقط من ١٠، و:396-398 يتجاهل بصمت أي نوع غير معروف). فالمستخدم يستطيع اليوم إعداد دورة اعتماد لإذن تسليم أو GRN من الشاشة: لا تُنشئ سجلات، ولا تحجب شيئًا، ولا تظهر في الصندوق. وهذا أسوأ من عدم وجودها — لأن المراجع/المالك سيصدّق أن الرقابة قائمة.| المحور | الموجود اليوم | المطلوب | ما يجب تغييره |
|---|---|---|---|
| تغطية المستندات | ٨ موصّلة · ٢ «ميتة» (DN/GRN) · ٦ مخازن غير موجودة | تغطية شاملة مبرَّرة | توصيل DN + GRN + (استلام/صرف/تسوية) — واستبعاد الجرد والتحويل والرصيد الافتتاحي بمبرر (§٨) |
| معنى «اعتماد» | في المخازن = ترحيل (يحرّك مخزون) | فصل: تخويل ثم ترحيل | إعادة تسمية زر المخازن إلى «استلام/صرف/ترحيل» + حصر ✅ الأخضر للاعتماد الإداري |
حالة PendingApproval | غير موجودة في المخازن إطلاقًا | موجودة | إضافة case للـenum — بدون migration (عمود الحالة نصّي) |
| مبلغ العتبة | المحرك يعتمد كليًّا على مبلغ (grand_total ?? total) | عتبة صحيحة لكل مستند | accessor افتراضي total لكل مستند مخزني (سابقة: PurchaseRequest.php:128-136) |
| خريطة المراجع | attachDocumentReferences = ٨ صفوف ثابتة، يتجاهل المجهول بصمت | كل نوع له صف | صف لكل نوع جديد وإلا يظهر الصندوق فارغًا بلا خطأ |
| مصدر قائمة الأنواع | مكرَّرة ٤ مرات: enum + شاشة الإعداد + الصندوق + الترجمة — بلا مصدر واحد | مصدر واحد | GET /core/approval-workflows/document-types يرجّع {module, document_type, labels, wired} والواجهة تُخفي غير الموصّل — يقتل فخّ DN/GRN للأبد |
| كتلة الاعتماد في الواجهة | منسوخة ٨ مرات (~١٣٠ سطر لكل شاشة) | مكوّن مشترك واحد | shared/components/approval-actions/ — قبل إضافة ٦ شاشات (وإلا تصير ١٤ نسخة) |
Modules/Core/app/Enums/ApprovalDocumentType.php — أنواع جديدة · ApprovalModule.php — case InventoryModules/Core/app/Services/ApprovalWorkflowService.php:382-391 — خريطة attachDocumentReferences (صف لكل نوع)Modules/Core/app/Support/DrivesApprovalWorkflow.php — الترايت (بدون تغيير متوقّع)SalesDeliveryNoteController · PurchaseGrnController · InventoryReceipt/Issue/AdjustmentController · (+ SalesOrderController/SalesQuotationController لإضافة الحارس المجاني)total افتراضي لكل مستند مخزنيPendingApproval — بلا migrationshared/components/approval-actions/ — البادج + أزرار الاعتماد/الرفض + ديالوج الرفضfeatures/sales/approval-workflows/* — يستهلك endpoint الأنواع بدل الخرائط الثابتة (:89-100, :102-113, :150-178)features/approvals/my-approvals.component.ts:67-92 — خرائط الـlabel والـroute (نوع غير مُعرَّف = لا رابط والمعتمِد لا يستطيع فتح المستند)core/models/inventory.model.ts — حقل approval · i18n: APPROVAL_WF.DT_* + MODULE_INVENTORY| الحالة | المعالجة |
|---|---|
| الجرد (Count) — فخّ الاعتماد المزدوج الحقيقي | finalize() لا يحرّك مخزونًا؛ ينتج «تسوية مسودة». لو اعتمدنا الجرد واعتمدنا التسوية، يُعتمد نفس الحدث مرتين. القرار: لا نوصّل الجرد — الرقابة تتم على التسوية التي ينتجها (وهي مغطّاة). |
| التحويل — المحرك لا يفهمه | المحرك مبني كليًّا على عتبة مبلغ (ApprovalWorkflowService:54-77). التحويل له كمية ولا قيمة → أي دورة إما تشتغل على كل مستند (min_amount=0) أو لا تشتغل أبدًا. وله نقطتا تنفيذ (ship + receive). ربطه صحيحًا يتطلب وضع عتبة كمّية في المحرك — تغيير في المحرك لا في الكنترولر. القرار: يُؤجَّل. |
| الرصيد الافتتاحي — لا حالة مسودة | store() ينشئ ويعتمد في نفس الترانزاكشن — لا يوجد ما يُحجَب. ربطه يتطلب إعادة هيكلة الـendpoint إلى (إنشاء مسودة + اعتماد). خطر عالٍ فعلًا لكن WP منفصل، لا يُحشر في هذه الجولة. |
| إذن التسليم يعتمد على إعداد | يخصم المخزون فقط لو sales.stock_deduction_point='delivery'. في شركات «الخصم عند الفاتورة» يظل الاعتماد ذا معنى (تخويل الالتزام تجاه العميل) لكنه لا يحمي مخزونًا. يُوثَّق للمالك. |
| الاستلام المُعتمَد تلقائيًّا | إعداد inventory.receipt_auto_approve قد يجعل الاستلام approved لحظة إنشائه. لو الاعتماد مطلوب، الأولوية للاعتماد — الإعداد لا يتخطّى الدورة. حالة اختبار إلزامية. |
| GRN بمسار الاستلام المؤجَّل | لو grnReceiptRequiresApproval=true يصير GRN → PendingReceipt والمخزون مؤجَّل لأمين المخزن. حجبان محتملان (GRN + الاستلام) — يجب ألّا نطلب اعتمادين للمبلغ نفسه. القرار: الاعتماد على GRN، والاستلام يتبعه. |
| تعدّد المستويات / تجاوز السقف | محسوم سلفًا في المحرك (يتصعّد لأعلى مستوى) — بلا تغيير. |
submit لكل مستند (يستدعي submitForApprovalIfConfigured ويضع الحالة PendingApproval)، والاعتماد يتم عبر المحرك، وزر التنفيذ الحالي (approve()/confirm()) يكتسب assertApprovedForPost() في أوله. النتيجة: المدير يخوّل القيمة → أمين المخزن يرحّل فعليًّا. عملان مختلفان حقًّا، وبلا ازدواج.approve() نفسه عبر المحرك — يجعل ضغطة آخر معتمِد هي التي تحرّك المخزون، فيفقد أمين المخزن دوره. مرفوض للاستلام تحديدًا.| WP | النطاق | الطبقة | الاختبار |
|---|---|---|---|
| WP1 | الطبقة المشتركة أولًا: (أ) endpoint أنواع المستندات (مصدر واحد + علم wired)؛ (ب) مكوّن approval-actions المشترك في الواجهة؛ (ج) إضافة assertApprovedForPost لأمر البيع وعرض السعر (الحارس المجاني). يصلح DN/GRN مجانًا لاحقًا. | BE+FE | Pest + ng build؛ الشاشات الثمانية الحالية تعمل كما هي بالضبط |
| WP2 [FIN] | الأخطر أولًا — التسوية والصرف: PendingApproval + submit + حارس على approve(). accessor total. صف في attachDocumentReferences. + إعادة التسمية (اعتماد → ترحيل/صرف). | BE+FE | معلّق ⇒ صفر حركة مخزون وصفر قيد (إثبات)؛ بلا ورك-فلو ⇒ سلوك مطابق |
| WP3 [FIN] | الاستلام المخزني + GRN: نفس النمط. مراعاة مسار PendingReceipt (اعتماد واحد لا اثنين) وreceipt_auto_approve. إعادة تسمية «استلام واعتماد» → «استلام». | BE+FE | GR-IR لا يُنشَأ قبل الاعتماد؛ auto_approve لا يتخطّى الدورة |
| WP4 | إذن التسليم: حارس على confirm() فقط. + توصيل شاشتي DN/GRN بالمكوّن المشترك. | BE+FE | وضعا الإعداد (خصم عند التسليم / عند الفاتورة) |
| WP5 | شاشة الإعداد + الصندوق: موديول «المخازن»؛ الشاشة تُغذّى من endpoint الأنواع؛ إخفاء غير الموصّل؛ خرائط label/route في الصندوق (وإلا لا رابط للمعتمِد). | FE | ng build؛ اختبار يدوي للمالك |
| WP6 | الصلاحيات: كل حركة اعتماد لها صلاحيتها ومنفَّذة (نطبّق درس الجولة السابقة). + إصلاح inventory.opening.create المستخدَم خطأً لزر الاعتماد. | BE+FE | deny/allow لكل حركة |
approved.)wired يجعل الواجهة تُخفي غير الموصّل، فيستحيل تكرار الفخّ.الشاشة الأخطر: التسويات المخزنية — ولنفس الشكل: الصرف والاستلام.
shared/components/approval-actions/ وتُستعمل في الـ١٤ شاشة — بدل ١٤ نسخة نسخ-ولصق.الطلب كان «نضيف مستندات». الفحص كشف أن الرقابة غائبة تمامًا حيث تُوجَع أكثر:
هذا تحليل — بانتظار موافقتك قبل أي كود.