توسيع سير الاعتماد إلى كل المستندات + مستندات المخازن

دراسة المرحلة الأولى (بحث فقط — صفر كود) · Moon ERP / moonui2 · [FIN] يمسّ حركة المخزون والترحيل المحاسبي

🔴 التسوية: تمسح مخزون بلا قيد وبلا توقيع ثانٍ 🔴 GRN: يحرّك مخزون ويعمل قيد — وغير موصّل ⚠️ «اعتماد» في المخازن = ترحيل وليس اعتمادًا 2026-07-13

١. المشكلة

المالك طلب: (أ) النمط المطبَّق على «طلب الشراء» يتطبّق على كل المستندات؛ (ب) نضيف مستندات المخازن لمحرك الاعتماد.

الفحص كشف إن المشكلة أعمق من مجرد «نضيف مستندات»:

🔴 اكتشاف ١ — التسوية المخزنية (Stock Adjustment) هي أقل مستند محكوم في النظام كله.
ApproveAdjustment.php:47-49 بيحرّك المخزون (يزوّد أو ينقّص) — بدون أي قيد محاسبي وبدون أي توقيع ثانٍ، بصلاحية واحدة فقط. دي القناة الكلاسيكية لسرقة/إخفاء عجز المخزون في أي ERP.
🔴 اكتشاف ٢ — سبب تأجيل الـGRN في الـKB «قديم وغير صحيح».
الـKB قال «GRN مالوش دورة submit→approve→post فاتأجّل». الحقيقة: PurchaseGrnController@approve :536 يحرّك المخزون و:539 يُنشئ قيد GR-IR. هو أهم مستند غير موصّل على الإطلاق.
⚠️ اكتشاف ٣ — تصحيح لفهمي المبدئي. قلت في البداية (نقلًا عن الـKB) إن «المخازن فيها اعتماد أمين مخزن بالفعل، فهيبقى اعتماد مزدوج». ده غير صحيح. الأدلة: ApproveReceipt::execute() يرفض إلا لو isDraft()، ثم يحرّك المخزون (:141ثم يكتب الحالة (:164) — كله في ترانزاكشن واحد. يعني كلمة «اعتماد» في المخازن معناها ترحيل. المستند له حالتان فقط: «لم يحدث شيء» و«المخزون تحرّك».
النتيجة: لا يوجد اعتماد مزدوج نخشاه — يوجد اعتماد غائب تمامًا. والخطر الحقيقي تسمية، لا ازدواج.

٢. الوضع الحالي (بالأدلة)

٢.١ نمط «طلب الشراء» — القالب المطلوب تعميمه

1
التقديم: PurchaseRequestController@submitApproval :210submitForApprovalIfConfigured()، والحالة تصير PendingApproval في الحالتين. المحرك يتجاهل قيمة الإرجاع عمدًا — وجود سجلات approval_logs (لا الحالة) هو ما يحكم كل شيء بعدها.
2
الاعتماد/الرفض: :238 لو hasApprovalLogs()approveViaEngine()؛ ولو الرد ليس fully_approved يرجع مبكرًا والمستند يفضل معلّقًا للمستوى التالي.
3
الخطوة المحجوبة ليست «ترحيلًا» — بل التحويل لأمر شراء: PurchaseOrderController@convertFromRequest :508assertApprovedForPost($request, PurchaseRequest). طلب الشراء ليس له مخزون ولا قيد، فالشيء الوحيد الجدير بالحجب هو استهلاكه بواسطة أمر الشراء.
مسار عدم الارتداد (no-regression): ApprovalWorkflowService::submitForApproval :40-50 يُرجِع auto_approved وينشئ صفر سجلات لو مفيش ورك-فلو مطابق. وقتها hasApprovalLogs() = false → الفرع الجديد يُتخطّى، وassertApprovedForPost() عملية لاغية موثَّقة (DrivesApprovalWorkflow.php:88,92-99). هذا هو الثابت الواجب حفظه في كل مستند جديد.

٢.٢ ماذا يفعل كل مستند غير موصّل بالضبط؟

المستندنقطة التنفيذ (commit)يحرّك مخزون؟يعمل قيد؟الواجب حجبه
إذن التسليم
SalesDeliveryNote
confirm() :303ConfirmDeliveryNoteنعم — فقط لو
stock_deduction_point='delivery'
لاconfirm() فقط.
ship()/deliver() مجرد كتابة حالة — حجبهما لا يفيد.
إذن الاستلام
PurchaseGRN
approve() :401نعم :536نعم — GR-IR :539approve()
استلام مخزني
InventoryReceipt
approve() :429ApproveReceiptنعم :141غير مباشر (GR-IR)approve() = ترحيل
صرف مخزني
InventoryIssue
approve() :225ApproveIssueنعم :195نعم — قيد تكلفة المبيعات COGSapprove() = ترحيل
تسوية مخزنية
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 من الشاشة: لا تُنشئ سجلات، ولا تحجب شيئًا، ولا تظهر في الصندوق. وهذا أسوأ من عدم وجودها — لأن المراجع/المالك سيصدّق أن الرقابة قائمة.

٣. المطلوب

  1. تعميم نمط «طلب الشراء» على كل المستندات (بما فيها إذن التسليم والـGRN المعلّقين).
  2. إضافة مستندات المخازن إلى المحرك.
  3. المستند المعلّق لا يحرّك مخزونًا ولا يعمل قيدًا — مُثبَتًا.
  4. عدم ارتداد: بلا ورك-فلو → السلوك كما هو بالضبط.
  5. واجهة: أزرار اعتماد/رفض + حالة على كل مستند + شاشة الإعداد تدعم المخازن + صلاحيات منفَّذة فعلًا.

٤. الفجوة (GAP)

المحورالموجود اليومالمطلوبما يجب تغييره
تغطية المستندات٨ موصّلة · ٢ «ميتة» (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/قبل إضافة ٦ شاشات (وإلا تصير ١٤ نسخة)

٥. الملفات والاعتماديات

Backend

Frontend

٦. الحالات الحدّية

الحالةالمعالجة
الجرد (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، والاستلام يتبعه.
تعدّد المستويات / تجاوز السقفمحسوم سلفًا في المحرك (يتصعّد لأعلى مستوى) — بلا تغيير.

٧. خطة التنفيذ (WPs)

الشكل المعتمد (Shape 1): «تخويل ثم ترحيل». نضيف submit لكل مستند (يستدعي submitForApprovalIfConfigured ويضع الحالة PendingApproval)، والاعتماد يتم عبر المحرك، وزر التنفيذ الحالي (approve()/confirm()) يكتسب assertApprovedForPost() في أوله. النتيجة: المدير يخوّل القيمة → أمين المخزن يرحّل فعليًّا. عملان مختلفان حقًّا، وبلا ازدواج.
الشكل المرفوض (Shape 2): تمرير approve() نفسه عبر المحرك — يجعل ضغطة آخر معتمِد هي التي تحرّك المخزون، فيفقد أمين المخزن دوره. مرفوض للاستلام تحديدًا.
WPالنطاقالطبقةالاختبار
WP1الطبقة المشتركة أولًا: (أ) endpoint أنواع المستندات (مصدر واحد + علم wired)؛ (ب) مكوّن approval-actions المشترك في الواجهة؛ (ج) إضافة assertApprovedForPost لأمر البيع وعرض السعر (الحارس المجاني). يصلح DN/GRN مجانًا لاحقًا.BE+FEPest + ng build؛ الشاشات الثمانية الحالية تعمل كما هي بالضبط
WP2 [FIN]الأخطر أولًا — التسوية والصرف: PendingApproval + submit + حارس على approve(). accessor total. صف في attachDocumentReferences. + إعادة التسمية (اعتماد → ترحيل/صرف).BE+FEمعلّق ⇒ صفر حركة مخزون وصفر قيد (إثبات)؛ بلا ورك-فلو ⇒ سلوك مطابق
WP3 [FIN]الاستلام المخزني + GRN: نفس النمط. مراعاة مسار PendingReceipt (اعتماد واحد لا اثنين) وreceipt_auto_approve. إعادة تسمية «استلام واعتماد» → «استلام».BE+FEGR-IR لا يُنشَأ قبل الاعتماد؛ auto_approve لا يتخطّى الدورة
WP4إذن التسليم: حارس على confirm() فقط. + توصيل شاشتي DN/GRN بالمكوّن المشترك.BE+FEوضعا الإعداد (خصم عند التسليم / عند الفاتورة)
WP5شاشة الإعداد + الصندوق: موديول «المخازن»؛ الشاشة تُغذّى من endpoint الأنواع؛ إخفاء غير الموصّل؛ خرائط label/route في الصندوق (وإلا لا رابط للمعتمِد).FEng build؛ اختبار يدوي للمالك
WP6الصلاحيات: كل حركة اعتماد لها صلاحيتها ومنفَّذة (نطبّق درس الجولة السابقة). + إصلاح inventory.opening.create المستخدَم خطأً لزر الاعتماد.BE+FEdeny/allow لكل حركة
مؤجَّل صراحةً (لا يُدّعى إنجازه): التحويل المخزني (يحتاج عتبة كمّية في المحرك) · الجرد (لا يجب توصيله) · الرصيد الافتتاحي (يحتاج إعادة هيكلة endpoint).

٨. قرارات تحتاج المالك

1
«اعتماد» في المخازن معناه ترحيل. نعيد تسميته؟
التوصية: نعم — نعيد التسمية. «استلام واعتماد» → «استلام»؛ الصرف → «صرف»؛ التسوية والرصيد → «ترحيل»؛ ونحصر ✅ الأخضر للاعتماد الإداري فقط. بدونها سيرى أمين المخزن زرَّي «اعتماد» أخضرين متجاورين بمعنيين متعاكسين. (تغيير مسمّى وأيقونة فقط — حالة الـBE تبقى approved.)
2
نطاق الجولة الأولى — أي مستندات؟
التوصية:التسوية + الصرف (الأخطر: تمحو مخزونًا/تُرحّل COGS) · ✅ الاستلام + GRN (يحرّكان مخزونًا ويعملان قيد GR-IR) · ✅ إذن التسليم.
الجرد — توصيله خطأ (لا يحرّك مخزونًا؛ الرقابة على التسوية التي ينتجها). ⏸ التحويل — المحرك لا يفهمه (عتبة مبلغ، والتحويل بلا قيمة). ⏸ الرصيد الافتتاحي — لا حالة مسودة، يحتاج إعادة هيكلة (WP منفصل).
3
إذن التسليم و GRN — نوصّلهما أم نحذفهما من القائمة؟
التوصية: نوصّلهما، ولا نحذفهما. كلاهما التزام مالي حقيقي (GRN يعمل قيد GR-IR؛ إذن التسليم قد يخصم مخزونًا). ووجودهما «قابلين للإعداد وبلا أثر» أسوأ من غيابهما — لأن المراجع سيصدّق أن الرقابة قائمة.
4
نبني المكوّن المشترك أولًا؟
التوصية: نعم، وقبل أي شاشة جديدة. كتلة الاعتماد منسوخة ٨ مرات؛ إضافة الشاشات الجديدة تجعلها ١٤ نسخة من نفس الـ١٣٠ سطر — مخالفة مباشرة لقاعدتك «الإصلاح في الطبقة المشتركة لا شاشة بشاشة». وبناؤه أولًا يصلح إذن التسليم والـGRN مجانًا.
5
مصدر واحد لأنواع المستندات (endpoint) بدل ٤ نسخ؟
التوصية: نعم. القائمة اليوم مكرَّرة ٤ مرات بلا مصدر — وهذا بالضبط سبب فخّ DN/GRN. الـendpoint مع علم wired يجعل الواجهة تُخفي غير الموصّل، فيستحيل تكرار الفخّ.

٩. معاينة الواجهة (قبل / بعد)

الشاشة الأخطر: التسويات المخزنية — ولنفس الشكل: الصرف والاستلام.

❌ قبل — «اعتماد» هو الترحيل، بلا أي رقابة

ADJ-000014تسوية عجز — مخزن الرئيسي − 48,500 ج.م ✓ اعتماد 🗑
ضغطة واحدة → المخزون يتمسح فورًا · صفر قيد محاسبي · صفر توقيع ثانٍ · صلاحية واحدة.

✅ بعد — تخويل ثم ترحيل (زرّان بمعنيين مختلفين، لا يلتبسان)

١) مسودة — والدورة مفعّلة للمبلغ ده:
ADJ-000014تسوية عجز − 48,500 📤 إرسال للاعتماد
٢) معلّق — زر الترحيل اختفى، والأخضر للمعتمِد فقط:
ADJ-000014تسوية عجز − 48,500 ⏳ بانتظار الاعتماد — مستوى ١ من ٢ (للمعتمِد المحدَّد فقط)
٣) بعد الاعتماد الكامل — الترحيل رجع، وباسمه الحقيقي:
ADJ-000014تسوية عجز − 48,500 ✓ معتمَد 📦 ترحيل
الفرق الجوهري: ✅ الأخضر = تخويل (توقيع المدير) · 📦 الكهرماني = ترحيل (أمين المخزن يحرّك المخزون فعليًّا). لم يعودا يتشابهان.
وبلا ورك-فلو معرَّف: الصف يظهر كما هو اليوم بالضبط — زر واحد (باسمه الجديد «ترحيل») بلا أي بادج. صفر ارتداد.
المكوّن المشترك: كتلة (البادج + ✓/✕ + ديالوج الرفض) تُبنى مرة واحدة في shared/components/approval-actions/ وتُستعمل في الـ١٤ شاشة — بدل ١٤ نسخة نسخ-ولصق.

الخلاصة

الطلب كان «نضيف مستندات». الفحص كشف أن الرقابة غائبة تمامًا حيث تُوجَع أكثر:

هذا تحليل — بانتظار موافقتك قبل أي كود.