نسخة مُحدَّثة بناءً على ملاحظاتك: (1) كيف يعرف المحاسب أن الفاتورة سُلِّم نصفها وأن جزءاً لم يُسلَّم؟ (2) كيف يعرف أمين المخزن أنه صرف نصف الكمية وأن هناك متبقّياً، وألّا يُنسى أو يُصرف مرتين؟ (3) إعدادات في المخازن والمبيعات لاختيار المسار. (4) إقفال الفاتورة على المُسلَّم لو الباقي لن يُسلَّم (إشعار دائن مالي). لا توجد بيانات قديمة (كلها تجريبية) → أُسقطت مرحلة التسوية.
fulfillment_status).quantity=100) · المنصرف (issued_quantity=50) · المتبقّي (50) — لا تخمين.over_delivery).هذه الحالة لم تكن مغطّاة في rev2 وأضفناها الآن (المرحلة 8). الحل = إشعار دائن مالي فقط للكمية غير المسلَّمة، دون أي حركة مخزون (لأن البضاعة لم تخرج أصلاً):
delivered=50, cancelled=50, الحالة short_closed (لا تظهر «بانتظار تسليم»). أي إذن متبقٍّ مفتوح يُلغى تلقائياً.
لكل بيع، يُسجَّل قيد Dr COGS / Cr Inventory بواسطة نفس الحدث الذي يُخرج البضاعة فعلياً، بنفس الكمية المنصرفة،
وبتكلفة لحظة الصرف. ولكل سطر فاتورة: المنصرف عبر كل الأذون ≤ المفوتر، والمتبقّي = المفوتر − المنصرف، مرئي دائماً.
// ثوابت يجب أن تتحقّق في كل لحظة
رصيد حساب المخزون GL == Σ StockBalance.total_value
COGS المعترف به == تكلفة ما خرج فعلياً فقط
Σ issued_quantity(سطر) == delivered_quantity(سطر فاتورة) ≤ quantity(المفوتر)
بدل فرض مسار واحد، نضيف إعدادات في المبيعات والمخازن تتيح اختيار سلوك مختلف لكل منشأة. كل الإعدادات الجديدة لها قيمة افتراضية تحافظ على السلوك الحالي (لا انحدار).
sales.*)| المفتاح | القيم | الافتراضي | الأثر |
|---|---|---|---|
sales.cogs_recognition_point جديد | invoice · delivery | invoice | متى يُسجَّل COGS: مع الفاتورة (المسار «أ») أم مع الصرف الفعلي (المسار «ب»). المفتاح الرئيسي الصريح بدل الاستنتاج الضمني. |
sales.auto_create_stock_issue_on_invoice (موجود) | true/false | false | توليد إذن صرف تلقائياً عند ترحيل الفاتورة. |
sales.auto_approve_stock_issue_on_invoice (موجود) | true/false | false | اعتماد الإذن المُولَّد فوراً (تسليم كامل آلي، بلا تدخّل مخزن). |
sales.allow_partial_delivery جديد | true/false | true | السماح لأمين المخزن بصرف أقل من المفوتر (صرف جزئي). |
sales.auto_create_backorder جديد | true/false | true | إنشاء إذن صرف Draft تلقائي للمتبقّي بعد الصرف الجزئي. |
sales.block_over_delivery جديد | true/false | true | منع صرف أكثر من المفوتر تراكمياً. |
sales.stock_deduction_point (موجود) | invoice · delivery | invoice | متى يُخصم المخزون فعلياً (يتناغم مع cogs_recognition_point). |
inventory.* + على مستوى المخزن)| المفتاح | القيم | الافتراضي | الأثر |
|---|---|---|---|
inventory.allow_partial_issue جديد | true/false | true | السماح بإدخال issued_quantity أقل من quantity على سطر الإذن. |
inventory.auto_approve_issues (موجود) | true/false | — | قد يمنع الاعتماد التلقائي حتى لو طلبته المبيعات. |
inventory.valuation_method (موجود) | fifo · weighted_avg | weighted_avg | أساس حساب التكلفة لقيد COGS. |
warehouse.allow_negative_stock (موجود، لكل مخزن) | true/false | false | السماح بالرصيد السالب (يسمح بالصرف رغم نقص المخزون). |
warehouse.require_issue_approval جديد (اختياري) | true/false | true | هل يلزم اعتماد منفصل للإذن أم يكفي الإنشاء. |
| المسار | التركيبة | مناسب لـ | الصرف الجزئي؟ |
|---|---|---|---|
| ① فوري بسيط | 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 | ثقة كاملة بالمخزون — الإذن يُعتمد آلياً بالكامل | لا |
التغيير الجوهري: سطر الإذن يحمل كميتين بدل واحدة، وحالة الإذن تكتسب وضعاً جزئياً. هذا ما يجعل «النصف المنصرف» و«المتبقّي» حقيقة مُسجَّلة لا استنتاجاً.
quantity = المطلوب (يُنسخ من الفاتورة = 100) — ثابت.issued_quantity جديد = المنصرف فعلاً (يدخله المخزن = 50)، افتراضي يساوي quantity.source_item_id جديد = يربط بسطر الفاتورة بدقة.quantity − issued_quantity (محسوب).enum IssueStatus الحالي: draft approved cancelled. نضيف:
partially_issued — اعتُمد الإذن لكن issued_quantity < quantity في سطر واحد على الأقل. حالة نهائية لهذا الإذن، والمتبقّي ينتقل لإذن back-order.
issued_quantity=50 (مسموح لأن allow_partial_issue=true) ويعتمد.
فحص: Σ issued عبر كل الأذون لهذا السطر ≤ 100 وإلا يُرفض (block_over_delivery).
Dr COGS 500 / Cr Inventory 500 · حالة الإذن = partially_issued ·
delivered_quantity(الفاتورة)=50 · fulfillment_status=partial.
auto_create_backorder=true)quantity=50 (المتبقّي)، يظهر في طابور المخزن. وإلا، زر يدوي «صرف المتبقّي».
Dr COGS 500 / Cr Inventory 500 · delivered_quantity=100 · fulfillment_status=full. اكتمل.
quantity وissued_quantity معاً يجعل «النصف المنصرف» و«النصف المتبقّي» حقيقة ظاهرة على المستند نفسه.fulfillment_status.مخزون GL = الفعلي دائماً.source_type=inventory_issue_cogs → مسار تدقيق من القيد للإذن للفاتورة.fulfillment_status ≠ full — لا يضيع متبقٍّ.over_delivery)، وفحص المخزون المتاح كما اليوم.نستخدم أحداث 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. |
كل مهمة: اختبار فاشل أولاً → حد أدنى → ينجح → commit. كل مهمة قابلة للمراجعة منفردة. كل مهمة باك-إند تنتهي بـ bash local-deploy.sh + php artisan test + chown moonui2:moonui2.
inventory_issue_items.issued_quantity (decimal, افتراضي = quantity)، .source_item_id (FK nullable).inventory_issues.cogs_journal_entry_id (FK nullable).sales_invoice_items.delivered_quantity (افتراضي 0)، sales_invoices.fulfillment_status (افتراضي 'pending').IssueStatus ← PartiallyIssued + label؛ enum FulfillmentStatus.SalesGlAccountResolver + FulfillmentServiceاستخراج حلّ الحسابات من PostSalesInvoice.php:305-334؛ وخدمة FulfillmentService: remainingFor(line), recomputeStatus(invoice), assertNotOverDelivered(line, qty).
issued_quantityissued_quantity (لا quantity) في الخصم والتكلفة؛ احترم allow_partial_issue؛ اضبط الحالة Approved أو PartiallyIssued؛ أطلق InventoryIssueApproved داخل المعاملة.issued_quantity لكل سطر.allow_partial_issue=false يمنع issued<quantityif ($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_recognition_pointPostSalesInvoice.php:52-58: سجّل COGS عند الفاتورة فقط إذا cogs_recognition_point=invoice (المسار «أ»). عند delivery: إيراد فقط، والمستمع (م4) يتولّى COGS.
=invoice → COGS عند الفاتورة (لا انحدار للمسار «أ»)=delivery → إيراد فقط عند الفاتورةPOST /api/sales/invoices/{id}/create-issueعندما يُقرَّر أن المتبقّي لن يُسلَّم: نُصدر إشعار دائن مالي فقط (بلا حركة مخزون) للكمية غير المسلَّمة، فيُقفل حساب العميل على ما استلمه بالضبط، وتعكسه الفاتورة بحالة نهائية.
affects_inventory (افتراضي true).affects_inventory=false — يبقى عكس الإيراد/الضريبة/الذمم فقط.POST /api/sales/invoices/{id}/close-short.sales_invoice_items.cancelled_quantity (افتراضي 0).ShortClosed؛ 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
Dr Revenue 750 / Dr VAT 112.5 / Cr AR 862.5، بلا حركة مخزون/COGSshort_closeddelivered + cancelled == invoiced (لا متبقٍّ معلّق)allow_short_close=false يمنع العمليةaffects_inventory=true).مخزون GL == Σ StockBalance.total_value./app (تنظيف chunks) + chown[Unreleased] (EN+{{ar}}) — الميزة + الإعدادات الجديدة + «فجوة الـseeder»| # | السيناريو | المتوقَّع |
|---|---|---|
| T1 | المسار «أ» (cogs=invoice, auto_create=false) | إيراد+COGS كامل عند الفاتورة — لا انحدار |
| T2 | المسار «ب» — فاتورة فقط | إيراد فقط، لا COGS |
| T3 | اعتماد إذن issued=50/مطلوب=100 | خصم50، COGS=500، حالة partially_issued، delivered=50, status=partial |
| T4 | back-order تلقائي للمتبقّي | إذن Draft 50 يُنشأ (وعند الإيقاف لا يُنشأ) |
| T5 | اعتماد المتبقّي 50 | COGS إجمالي=1000، status=full |
| T6 | over-delivery (محاولة صرف 60 من متبقٍّ 50) | رفض block_over_delivery |
| T7 | allow_partial_issue=false | منع issued<quantity |
| T8 | إلغاء إذن معتمَد | عكس COGS، delivered يُخصم، GL=الفعلي |
| T9 | FIFO و weighted_avg | COGS=تكلفة الحركة في الحالتين |
| T10 | idempotency (اعتماد/حدث مزدوج) | قيد واحد |
| T11 | إذن إنتاج لا بيعي | لا COGS مبيعات |
| T12 | tie-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 |
| الخطر | التخفيف |
|---|---|
| فشل المستمع يترك مخزوناً منصرفاً بلا قيد | متزامن داخل معاملة الاعتماد → الفشل يُرجع كل شيء (ذرّية) |
| ازدواج قيد COGS | م5 توقف قيد الفاتورة في وضع التسليم + idempotency + حارس cogs_journal_entry_id |
| كسر المسار «أ» (الأغلبية) | محفوظ خلف cogs_recognition_point=invoice + T1 |
| ضياع المتبقّي | back-order تلقائي + طابور التسليمات + over-delivery guard |
| اختلاف تكلفة FIFO إطلالة/استهلاك | T9 يثبّتها؛ المصدر = قيمة الحركة الفعلية |
التراجع: كل مرحلة commit منفصل؛ الميزة محوطة بالإعدادات (تعطيلها يعيد المسار «أ»)؛ الأعمدة nullable/افتراضية فالهجرات عكوسة.
pint نظيف · مراجعة opus نهائية · FE منشور · changelog+KB محدّثان.1 DB+إعدادات → 2 خدمات → 3 حدث+issued_qty → 4 مستمع COGS+back-order → 5 توجيه COGS → 6 إلغاء → 7 المتبقّي+worklist → 8 إقفال قصير/إشعار دائن → 9 تقرير+tie-out → 10 FE → 11 توثيق
المراحل 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-170 | issued_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-150 | tie-out (م8) |
| EventServiceProvider (Inv/Sales) | {Inventory,Sales}/.../Providers/EventServiceProvider.php | تسجيل (م3،4،6) |