Moon ERP · خطة تنفيذ — الحل الدائم (نسخة مُحدَّثة)

التسليم الجزئي + ربط التكلفة بالتسليم الفعلي — ومن يرى ماذا، وبأي إعداد Partial delivery, COGS-at-issue, role visibility & selectable flows via settings

نسخة مُحدَّثة بناءً على ملاحظاتك: (1) كيف يعرف المحاسب أن الفاتورة سُلِّم نصفها وأن جزءاً لم يُسلَّم؟ (2) كيف يعرف أمين المخزن أنه صرف نصف الكمية وأن هناك متبقّياً، وألّا يُنسى أو يُصرف مرتين؟ (3) إعدادات في المخازن والمبيعات لاختيار المسار. (4) إقفال الفاتورة على المُسلَّم لو الباقي لن يُسلَّم (إشعار دائن مالي). لا توجد بيانات قديمة (كلها تجريبية) → أُسقطت مرحلة التسوية.

📅 2026-06-22 (rev2) الفرع: hazemdev2 → main التنفيذ: subagent-driven + Pest TDD جديد: نموذج صرف جزئي + رؤية الأدوار + إعدادات المسارات
المحتويات

إجابات أسئلتك الأربعة (ملخّص الحل)

① كيف يعرف المحاسب أن الفاتورة سُلِّم نصفها وأن جزءاً لم يُسلَّم؟

② كيف يعرف أمين المخزن أنه صرف نصف الكمية؟

③ لو صُرف نصفه — كيف لا يُنسى المتبقّي أو يُصرف مرتين؟

④ لو الباقي (50) مش هيتسلّم خالص — كيف نُقفل حساب العميل ونعكسه على الفاتورة بدقة؟

هذه الحالة لم تكن مغطّاة في rev2 وأضفناها الآن (المرحلة 8). الحل = إشعار دائن مالي فقط للكمية غير المسلَّمة، دون أي حركة مخزون (لأن البضاعة لم تخرج أصلاً):

Dr Sales Revenue 750 (عكس إيراد الـ50 غير المسلَّمة) Dr VAT Payable 112.5 (عكس ضريبتها) Cr Accounts Receivable 862.5 (العميل يدين بقيمة الـ50 المسلَّمة فقط)

1 المبدأ الحاكم (الـ Invariant)

لكل بيع، يُسجَّل قيد Dr COGS / Cr Inventory بواسطة نفس الحدث الذي يُخرج البضاعة فعلياً، بنفس الكمية المنصرفة، وبتكلفة لحظة الصرف. ولكل سطر فاتورة: المنصرف عبر كل الأذون ≤ المفوتر، والمتبقّي = المفوتر − المنصرف، مرئي دائماً.

// ثوابت يجب أن تتحقّق في كل لحظة
رصيد حساب المخزون GL   ==  Σ StockBalance.total_value
COGS المعترف به        ==  تكلفة ما خرج فعلياً فقط
Σ issued_quantity(سطر) ==  delivered_quantity(سطر فاتورة)  ≤  quantity(المفوتر)

2 الإعدادات والمسارات القابلة للاختيار مطلوبك

بدل فرض مسار واحد، نضيف إعدادات في المبيعات والمخازن تتيح اختيار سلوك مختلف لكل منشأة. كل الإعدادات الجديدة لها قيمة افتراضية تحافظ على السلوك الحالي (لا انحدار).

إعدادات المبيعات (sales.*)

المفتاحالقيمالافتراضيالأثر
sales.cogs_recognition_point جديدinvoice · deliveryinvoiceمتى يُسجَّل COGS: مع الفاتورة (المسار «أ») أم مع الصرف الفعلي (المسار «ب»). المفتاح الرئيسي الصريح بدل الاستنتاج الضمني.
sales.auto_create_stock_issue_on_invoice (موجود)true/falsefalseتوليد إذن صرف تلقائياً عند ترحيل الفاتورة.
sales.auto_approve_stock_issue_on_invoice (موجود)true/falsefalseاعتماد الإذن المُولَّد فوراً (تسليم كامل آلي، بلا تدخّل مخزن).
sales.allow_partial_delivery جديدtrue/falsetrueالسماح لأمين المخزن بصرف أقل من المفوتر (صرف جزئي).
sales.auto_create_backorder جديدtrue/falsetrueإنشاء إذن صرف Draft تلقائي للمتبقّي بعد الصرف الجزئي.
sales.block_over_delivery جديدtrue/falsetrueمنع صرف أكثر من المفوتر تراكمياً.
sales.stock_deduction_point (موجود)invoice · deliveryinvoiceمتى يُخصم المخزون فعلياً (يتناغم مع cogs_recognition_point).

إعدادات المخازن (inventory.* + على مستوى المخزن)

المفتاحالقيمالافتراضيالأثر
inventory.allow_partial_issue جديدtrue/falsetrueالسماح بإدخال issued_quantity أقل من quantity على سطر الإذن.
inventory.auto_approve_issues (موجود)true/falseقد يمنع الاعتماد التلقائي حتى لو طلبته المبيعات.
inventory.valuation_method (موجود)fifo · weighted_avgweighted_avgأساس حساب التكلفة لقيد COGS.
warehouse.allow_negative_stock (موجود، لكل مخزن)true/falsefalseالسماح بالرصيد السالب (يسمح بالصرف رغم نقص المخزون).
warehouse.require_issue_approval جديد (اختياري)true/falsetrueهل يلزم اعتماد منفصل للإذن أم يكفي الإنشاء.

ثلاثة مسارات جاهزة (Presets) — يختار المستخدم واحداً

المسارالتركيبةمناسب لـالصرف الجزئي؟
① فوري بسيط cogs=invoice auto_create=false تجزئة/POS — تسليم لحظي، فاتورة = خروج فوري لا
② مدفوع بالتسليم المُوصى به للجملة cogs=delivery auto_create=true auto_approve=false allow_partial=true auto_backorder=true جملة/مخزون حقيقي — المخزن يصرف فعلياً (كامل أو جزئي) نعم
③ تسليم آلي كامل cogs=delivery auto_create=true auto_approve=true ثقة كاملة بالمخزون — الإذن يُعتمد آلياً بالكامل لا
المسار ① هو السلوك الحالي تماماً (محفوظ). المسار ② هو الذي يحقّق سؤالك بالكامل. تبديل المسار = تغيير إعدادات فقط، بلا كود.

3 نموذج الصرف الجزئي (مطلوب / منصرف / متبقٍّ)

التغيير الجوهري: سطر الإذن يحمل كميتين بدل واحدة، وحالة الإذن تكتسب وضعاً جزئياً. هذا ما يجعل «النصف المنصرف» و«المتبقّي» حقيقة مُسجَّلة لا استنتاجاً.

سطر الإذن — أعمدة جديدة

  • quantity = المطلوب (يُنسخ من الفاتورة = 100) — ثابت.
  • issued_quantity جديد = المنصرف فعلاً (يدخله المخزن = 50)، افتراضي يساوي quantity.
  • source_item_id جديد = يربط بسطر الفاتورة بدقة.
  • المتبقّي = quantity − issued_quantity (محسوب).

حالة الإذن — قيمة جديدة

enum IssueStatus الحالي: draft approved cancelled. نضيف:

partially_issued — اعتُمد الإذن لكن issued_quantity < quantity في سطر واحد على الأقل. حالة نهائية لهذا الإذن، والمتبقّي ينتقل لإذن back-order.

تدفّق الصرف الجزئي

1

المخزن يفتح الإذن (مطلوب 100) ويُدخل المنصرف 50

يُدخل issued_quantity=50 (مسموح لأن allow_partial_issue=true) ويعتمد. فحص: Σ issued عبر كل الأذون لهذا السطر ≤ 100 وإلا يُرفض (block_over_delivery).
2

عند الاعتماد

خصم المخزون 50 · قيد Dr COGS 500 / Cr Inventory 500 · حالة الإذن = partially_issued · delivered_quantity(الفاتورة)=50 · fulfillment_status=partial.
3

إنشاء إذن المتبقّي تلقائياً (إن كان auto_create_backorder=true)

إذن Draft جديد مربوط بنفس الفاتورة، quantity=50 (المتبقّي)، يظهر في طابور المخزن. وإلا، زر يدوي «صرف المتبقّي».
4

اعتماد إذن المتبقّي (50)

قيد Dr COGS 500 / Cr Inventory 500 · delivered_quantity=100 · fulfillment_status=full. اكتمل.
لماذا «المنصرف» على السطر وليس مجرد تعديل الكمية؟ لأن تعديل الكمية لـ50 يُفقد المعلومة أن المطلوب كان 100 — فلا يعرف المخزن ولا المحاسب أن هناك متبقّياً. تسجيل quantity وissued_quantity معاً يجعل «النصف المنصرف» و«النصف المتبقّي» حقيقة ظاهرة على المستند نفسه.

4 رؤية المحاسب · رؤية أمين المخزن

👔 رؤية المحاسب

  • على الفاتورة: أعمدة مفوتر/مُسلَّم/متبقٍّ لكل سطر + شارة fulfillment_status.
  • تقرير «مفوتر ولم يُسلَّم»: كل الفواتير ذات إيراد معترف وبضاعة لم تكتمل، بقيمة المتبقّي — للمتابعة والإقفال الشهري.
  • دفتر الأستاذ: COGS يتبع التسليم؛ فحص tie-out يضمن مخزون GL = الفعلي دائماً.
  • قيد كل تسليم موسوم source_type=inventory_issue_cogs → مسار تدقيق من القيد للإذن للفاتورة.

📦 رؤية أمين المخزن

  • على الإذن: مطلوب/منصرف/متبقٍّ صريحة + حالة partially_issued.
  • طابور التسليمات (worklist): فلتر بالأذون Draft + الفواتير ذات fulfillment_status ≠ full — لا يضيع متبقٍّ.
  • إذن المتبقّي يُنشأ تلقائياً ويظهر كـ Draft بانتظار الصرف.
  • حماية: لا يمكن صرف أكثر من المتبقّي (رفض over_delivery)، وفحص المخزون المتاح كما اليوم.

5 المعمارية والمكوّنات

نستخدم أحداث Laravel لإبقاء Inventory غير معتمِدة على Sales/Accounting (Inventory تُطلق، Sales تستمع وتُسجِّل) — مطابق لنمط InvoicePosted القائم.

المكوّنالنوعالدور
InventoryIssueApproved / InventoryIssueCancelledحدث (Inventory)يُطلقان داخل المعاملة عند الاعتماد/الإلغاء.
PostSaleCogsOnIssueApprovedمستمع (Sales)للأذون البيعية: قيد COGS بالمنصرف + تحديث delivered_quantity/الحالة + إنشاء back-order.
ReverseSaleCogsOnIssueCancelledمستمع (Sales)عكس قيد COGS وإعادة حساب التسليم.
SalesGlAccountResolverخدمة (Sales)استخراج حلّ حسابات COGS/المخزون (DRY) من PostSalesInvoice.
FulfillmentServiceخدمة (Sales)حساب delivered/remaining/status + فحص over-delivery + بناء إذن المتبقّي.
أعمدة DBهجراتissued_quantity, source_item_id, cogs_journal_entry_id, delivered_quantity, fulfillment_status
إعداداتsetting defs + seederالمفاتيح الجديدة في القسم 2.

6 المراحل والمهام (TDD)

كل مهمة: اختبار فاشل أولاً → حد أدنى → ينجح → commit. كل مهمة قابلة للمراجعة منفردة. كل مهمة باك-إند تنتهي بـ bash local-deploy.sh + php artisan test + chown moonui2:moonui2.

1

أسس DB + تعريفات الإعدادات

منخفض
  • migration: inventory_issue_items.issued_quantity (decimal, افتراضي = quantity)، .source_item_id (FK nullable).
  • migration: inventory_issues.cogs_journal_entry_id (FK nullable).
  • migration: sales_invoice_items.delivered_quantity (افتراضي 0)، sales_invoices.fulfillment_status (افتراضي 'pending').
  • enum IssueStatusPartiallyIssued + label؛ enum FulfillmentStatus.
  • setting definitions + seeder: كل مفاتيح القسم 2 بقيمها الافتراضية.
  • $fillable/$casts للموديلات الأربعة.
  • اختبار: الأعمدة + الافتراضيات + قابلية الكتابة
  • اختبار: تعريفات الإعدادات تُقرأ بقيمها الافتراضية
القبول: هجرات تمر، إعدادات افتراضية مقروءة، لا تغيير سلوكي بعد.
2

استخراج SalesGlAccountResolver + FulfillmentService

منخفض

استخراج حلّ الحسابات من PostSalesInvoice.php:305-334؛ وخدمة FulfillmentService: remainingFor(line), recomputeStatus(invoice), assertNotOverDelivered(line, qty).

  • اختبار وحدة لأولوية حلّ الحساب (مطابق للحالي)
  • اختبار وحدة لحسابات remaining/status/over-delivery
  • إعادة توجيه PostSalesInvoice للخدمة — سلوك مطابق
3

حدث الاعتماد + إدخال issued_quantity

متوسط
  • ApproveIssue.php: استخدم issued_quantity (لا quantity) في الخصم والتكلفة؛ احترم allow_partial_issue؛ اضبط الحالة Approved أو PartiallyIssued؛ أطلق InventoryIssueApproved داخل المعاملة.
  • طلب التحديث/الاعتماد + المتحكّم: قبول issued_quantity لكل سطر.
  • اختبار: اعتماد بـ issued=50/مطلوب=100 → خصم 50، حالة partially_issued
  • اختبار: over-delivery مرفوض
  • اختبار: allow_partial_issue=false يمنع issued<quantity
  • اختبار: الحدث يُطلَق مرة واحدة
4

المستمع: COGS عند التسليم + delivered + back-order

متوسط · القلب
  • PostSaleCogsOnIssueApproved.php + تسجيله في Sales EventServiceProvider.
if ($issue->reference_type !== Sale) return;
if ($issue->cogs_journal_entry_id) return;                 // idempotent
$je = createJournalEntry(Dr COGS / Cr Inventory = Σ items.total_cost,
        source_type='inventory_issue_cogs', idempotency="issue-cogs-{id}");
$issue->update(['cogs_journal_entry_id'=>$je->id]);
fulfillment->applyDelivery($issue);          // delivered_quantity += issued, recompute status
if (settings('sales.auto_create_backorder') && remaining>0)
        fulfillment->createBackorderIssue($invoice);
  • اختبار: قيد COGS بالمنصرف (500 لا 1000)
  • اختبار: delivered/status يُحدَّثان
  • اختبار: back-order Draft يُنشأ بالمتبقّي عند تفعيله، ولا يُنشأ عند إيقافه
  • اختبار: إذن غير بيعي لا يتأثّر · idempotency · FIFO+WAC
5

توجيه COGS بمفتاح cogs_recognition_point

متوسط

PostSalesInvoice.php:52-58: سجّل COGS عند الفاتورة فقط إذا cogs_recognition_point=invoice (المسار «أ»). عند delivery: إيراد فقط، والمستمع (م4) يتولّى COGS.

  • اختبار: =invoice → COGS عند الفاتورة (لا انحدار للمسار «أ»)
  • اختبار: =delivery → إيراد فقط عند الفاتورة
  • اختبار تكامل: فاتورة 100 → صرف 50 → COGS=500 فقط، GL=الفعلي
تغيير سلوكي مقصود لمن يفعّل المسار «ب». محفوظ خلف الإعداد، والافتراضي يبقى «أ».
6

الإلغاء: عكس COGS + خصم delivered

متوسط
  • InventoryIssueCancelled + إطلاقه في CancelIssue.php
  • ReverseSaleCogsOnIssueCancelled + تسجيله
  • CancelSalesInvoice: تسلسل إلغاء الأذون
  • اختبار: اعتماد ثم إلغاء → صافي COGS=0، GL=الفعلي
  • اختبار: delivered يُخصم والحالة تُعاد
  • اختبار: إلغاء فاتورة يسلسل لأذونها
7

صرف المتبقّي يدوياً + طابور التسليمات (API)

متوسط
  • action CreateRemainingIssue + POST /api/sales/invoices/{id}/create-issue
  • endpoint قائمة: الفواتير/الأذون ذات المتبقّي (worklist) للمخزن.
  • اختبار: إنشاء إذن متبقٍّ ينتج الكمية الصحيحة، يمنع لو full
  • اختبار: worklist يُرجع غير المكتمل فقط · صلاحيات · نطاق شركة
8

إقفال الفاتورة على المُسلَّم — إشعار دائن للمتبقّي غير المُسلَّم

متوسط · مطلوبك

عندما يُقرَّر أن المتبقّي لن يُسلَّم: نُصدر إشعار دائن مالي فقط (بلا حركة مخزون) للكمية غير المسلَّمة، فيُقفل حساب العميل على ما استلمه بالضبط، وتعكسه الفاتورة بحالة نهائية.

  • migration/model SalesReturn: عمود affects_inventory (افتراضي true).
  • PostSalesReturn::handleReverseCogs (PostSalesReturn.php:158): تخطّي ساق المخزون/COGS عندما affects_inventory=false — يبقى عكس الإيراد/الضريبة/الذمم فقط.
  • action CloseInvoiceShort + POST /api/sales/invoices/{id}/close-short.
  • migration: sales_invoice_items.cancelled_quantity (افتراضي 0).
  • enum FulfillmentStatusShortClosed؛ setting sales.allow_short_close (افتراضي true).
// CloseInvoiceShort: للكمية المتبقّية غير المسلَّمة (= quantity − delivered)
$return = SalesReturn(invoice, affects_inventory=false, items=remaining @ سعر الفاتورة);
postSalesReturn($return);              // Dr Revenue/VAT / Cr AR فقط — لا مخزون
$line->update(['cancelled_quantity'=>$remaining]);
cancelOpenBackorderIssues($invoice);   // أي إذن متبقٍّ مفتوح يُلغى
fulfillment->markShortClosed($invoice); // delivered + cancelled == invoiced
  • اختبار: إقفال قصير لـ50 → قيد Dr Revenue 750 / Dr VAT 112.5 / Cr AR 862.5، بلا حركة مخزون/COGS
  • اختبار: رصيد ذمم الفاتورة = قيمة المُسلَّم فقط؛ الحالة short_closed
  • اختبار: أي إذن back-order مفتوح يُلغى تلقائياً
  • اختبار: delivered + cancelled == invoiced (لا متبقٍّ معلّق)
  • اختبار: allow_short_close=false يمنع العملية
لماذا مالي فقط؟ الـ50 لم تُصرف من المخزن (نموذج COGS-عند-التسليم) فلا COGS لعكسه ولا بضاعة لإرجاعها — نُصحّح الإيراد/الضريبة/الذمم فقط. يختلف عن «مرتجع مبيعات» عادي (بضاعة مُسلَّمة تعود للمخزن، حيث affects_inventory=true).
9

تقرير المحاسب + فحص الاتساق tie-out

منخفض
  • تقرير «مفوتر ولم يُسلَّم» (Sales) — فواتير fulfillment≠full بقيمة المتبقّي.
  • AccountingIntegrityService: فحص مخزون GL == Σ StockBalance.total_value.
  • اختبار: التقرير يسرد الجزئي بقيمة صحيحة
  • اختبار: tie-out يمر في السيناريو الصحيح ويكشف فرقاً مصطنعاً
10

الواجهة (Angular) — الأدوار

متوسط
  • شاشة الإعدادات (المبيعات/المخازن): المفاتيح الجديدة + اختيار المسار (preset).
  • عرض/قائمة الفاتورة: أعمدة مُسلَّم/متبقٍّ + شارة الحالة + زر «صرف المتبقّي».
  • شاشة إذن الصرف: حقل «المنصرف» مقابل «المطلوب» + المتبقّي + حالة partially_issued.
  • شاشة طابور التسليمات + i18n (en/ar).
  • شارات/أعمدة بترجمة ثنائية
  • بناء FE + نشر /app (تنظيف chunks) + chown
11

التوثيق + changelog

منخفض
  • بند [Unreleased] (EN+{{ar}}) — الميزة + الإعدادات الجديدة + «فجوة الـseeder»
  • تحديث KB + إغلاق بند INDEX
لا مرحلة تسوية بيانات قديمة — أكّدتَ أن كل الفواتير تجريبية. إن لزم، تُمسح/يُعاد البذر فقط.

7 مصفوفة الاختبارات (Pest)

#السيناريوالمتوقَّع
T1المسار «أ» (cogs=invoice, auto_create=false)إيراد+COGS كامل عند الفاتورة — لا انحدار
T2المسار «ب» — فاتورة فقطإيراد فقط، لا COGS
T3اعتماد إذن issued=50/مطلوب=100خصم50، COGS=500، حالة partially_issued، delivered=50, status=partial
T4back-order تلقائي للمتبقّيإذن Draft 50 يُنشأ (وعند الإيقاف لا يُنشأ)
T5اعتماد المتبقّي 50COGS إجمالي=1000، status=full
T6over-delivery (محاولة صرف 60 من متبقٍّ 50)رفض block_over_delivery
T7allow_partial_issue=falseمنع issued<quantity
T8إلغاء إذن معتمَدعكس COGS، delivered يُخصم، GL=الفعلي
T9FIFO و weighted_avgCOGS=تكلفة الحركة في الحالتين
T10idempotency (اعتماد/حدث مزدوج)قيد واحد
T11إذن إنتاج لا بيعيلا COGS مبيعات
T12tie-out بعد كل سيناريوGL inventory == Σ stock value
T13تقرير «مفوتر ولم يُسلَّم»يسرد الجزئي بقيمة المتبقّي
T14إقفال قصير (المتبقّي 50 لن يُسلَّم)Dr Revenue 750 / Dr VAT 112.5 / Cr AR 862.5، بلا مخزون/COGS، status=short_closed، ذمم الفاتورة=قيمة المُسلَّم
T15إقفال قصير مع back-order مفتوحالإذن المتبقّي يُلغى، delivered+cancelled==invoiced

8 المخاطر · القبول · التنفيذ · المراجع

المخاطر والتراجع

الخطرالتخفيف
فشل المستمع يترك مخزوناً منصرفاً بلا قيدمتزامن داخل معاملة الاعتماد → الفشل يُرجع كل شيء (ذرّية)
ازدواج قيد COGSم5 توقف قيد الفاتورة في وضع التسليم + idempotency + حارس cogs_journal_entry_id
كسر المسار «أ» (الأغلبية)محفوظ خلف cogs_recognition_point=invoice + T1
ضياع المتبقّيback-order تلقائي + طابور التسليمات + over-delivery guard
اختلاف تكلفة FIFO إطلالة/استهلاكT9 يثبّتها؛ المصدر = قيمة الحركة الفعلية

التراجع: كل مرحلة commit منفصل؛ الميزة محوطة بالإعدادات (تعطيلها يعيد المسار «أ»)؛ الأعمدة nullable/افتراضية فالهجرات عكوسة.

معايير القبول (DoD)

ترتيب التنفيذ

1 DB+إعدادات2 خدمات3 حدث+issued_qty4 مستمع COGS+back-order5 توجيه COGS6 إلغاء7 المتبقّي+worklist8 إقفال قصير/إشعار دائن9 تقرير+tie-out10 FE11 توثيق

المراحل 1–5 = الحد الأدنى القابل للإطلاق (يصلح المحاسبة + الصرف الجزئي الأساسي). 6–10 تكمل التجربة والرؤية (الإلغاء، الإقفال القصير، التقارير، الواجهة). التنفيذ subagent-driven: implementer ثم task-reviewer ثم حلقة إصلاح، ومراجعة opus نهائية. النماذج: sonnet للتنفيذ، haiku للمراجعات الصغيرة، opus للنهائية.

ملحق: مراجع الكود

المكوّنالملف:السطرالإجراء
ترحيل الفاتورة (إيراد/COGS/إذن)Sales/.../PostSalesInvoice.php:32-94, 179-401توجيه COGS + source_item_id (م5،1)
حلّ حسابات المخزون (للاستخراج)PostSalesInvoice.php:305-334استخراج (م2)
اعتماد الإذنInventory/.../ApproveIssue.php:22-170issued_quantity + حدث + حالة (م3)
إلغاء الإذنInventory/.../CancelIssue.php:19-64حدث عكس (م6)
حالة الإذن (enum)Inventory/.../Enums/IssueStatus.php+ PartiallyIssued (م1)
سطر الإذن (fillable/casts)Inventory/.../Models/InventoryIssueItem.php:19-44+ issued_quantity/source_item_id (م1)
محرّك القيودAccounting/.../CreateJournalEntry.php:27استهلاك (م4،6)
خدمة النزاهةAccounting/.../AccountingIntegrityService.php:24-150tie-out (م8)
EventServiceProvider (Inv/Sales){Inventory,Sales}/.../Providers/EventServiceProvider.phpتسجيل (م3،4،6)