From 033bf04f60837d772a0092fe54978f4a14a7ebb1 Mon Sep 17 00:00:00 2001 From: Dirk van der Laarse Date: Wed, 27 May 2026 06:47:55 +0000 Subject: [PATCH 1/3] feat: skip the value/payable calculation when is_manufactured = 1 TASK-2026-00203 --- erpnext_mrp/mrp/tasks/mrp_run.py | 261 ++++++++++++++++--------------- 1 file changed, 131 insertions(+), 130 deletions(-) diff --git a/erpnext_mrp/mrp/tasks/mrp_run.py b/erpnext_mrp/mrp/tasks/mrp_run.py index c09a0f5..8f17705 100644 --- a/erpnext_mrp/mrp/tasks/mrp_run.py +++ b/erpnext_mrp/mrp/tasks/mrp_run.py @@ -882,139 +882,140 @@ def _finalise_item_batch( mrp_entry_docs[0].days_to_reorder_excl_reorder_level = _NO_REORDER_SENTINEL mrp_entry_docs[0].needs_reorder_excl_reorder_level = 0 - if price and price > 0: - for entry in mrp_entry_docs: - if entry.suggested_orders: - new_value = entry.suggested_orders * price - if entry.suggested_orders_value != new_value: - entry.suggested_orders_value = new_value - - supplier_name = item_details.get("default_supplier") if item_details else None - payment_terms_template = supplier_payment_terms.get(supplier_name) if supplier_name else None - - if supplier_name and payment_terms_template: - entry_map = {e.name: e for e in mrp_entry_docs} - for entry in mrp_entry_docs: - if entry.suggested_orders_value: + if not mrp_entry_docs[0].is_manufactured: + if price and price > 0: + for entry in mrp_entry_docs: + if entry.suggested_orders: + new_value = entry.suggested_orders * price + if entry.suggested_orders_value != new_value: + entry.suggested_orders_value = new_value + + supplier_name = item_details.get("default_supplier") if item_details else None + payment_terms_template = supplier_payment_terms.get(supplier_name) if supplier_name else None + + if supplier_name and payment_terms_template: + entry_map = {e.name: e for e in mrp_entry_docs} + for entry in mrp_entry_docs: + if entry.suggested_orders_value: + schedule = get_payment_terms( + payment_terms_template, + posting_date=entry.target_date, + grand_total=entry.suggested_orders_value, + base_grand_total=entry.suggested_orders_value, + ) + if schedule: + for term in schedule: + base_date = None + payment_term_name = term.get("payment_term") + if payment_term_name: + custom_due_date_type = payment_term_details.get( + payment_term_name, {} + ).get("custom_due_date") + if custom_due_date_type == "Order date": + base_date = entry.target_date + elif custom_due_date_type == "Shipment date": + base_date = add_days( + entry.target_date, item_details.get("primary_lead_time") or 0 + ) + elif custom_due_date_type == "Arrival date": + total_lead_time = (item_details.get("primary_lead_time") or 0) + ( + item_details.get("additional_lead_time") or 0 + ) + base_date = add_days(entry.target_date, total_lead_time) + else: + base_date = entry.target_date + if base_date: + term.due_date = get_due_date(term, posting_date=base_date) + due_date = term.get("due_date") + payment_amount = term.get("payment_amount") + if due_date and payment_amount: + year, week, _day = getdate(due_date).isocalendar() + target_name = f"{item_code}-{year}CW{week:02d}" + target_entry = entry_map.get(target_name) + if target_entry: + target_entry.suggested_orders_value_payable = ( + target_entry.suggested_orders_value_payable or 0 + ) + payment_amount + elif getdate(due_date) < mrp_entry_docs[0].target_date: + mrp_entry_docs[0].suggested_orders_value_payable = ( + mrp_entry_docs[0].suggested_orders_value_payable or 0 + ) + payment_amount + else: + for entry in mrp_entry_docs: + entry.suggested_orders_value_payable = entry.suggested_orders_value + + # Calculate Scheduled Receipts Payable using actual PO dates. + # Each open PO line is processed individually so its own transaction_date (order), + # schedule_date (shipment/ETD), and custom_expected_arrival_date (arrival/ETA) can be used + # as the base date for the matching payment term type. + item_po_lines = po_lines_by_item.get(item_code, []) + + if supplier_name and payment_terms_template and item_po_lines: + entry_map = {e.name: e for e in mrp_entry_docs} + for po_line in item_po_lines: + remaining_value = po_line.get("remaining_value") or 0 + if not remaining_value: + continue + + order_date = ( + getdate(po_line.get("transaction_date")) if po_line.get("transaction_date") else None + ) + shipment_date = ( + getdate(po_line.get("schedule_date")) if po_line.get("schedule_date") else None + ) + arrival_date = ( + getdate(po_line.get("custom_expected_arrival_date")) + if po_line.get("custom_expected_arrival_date") + else shipment_date + ) + posting_date = arrival_date or shipment_date or order_date or date.today() + schedule = get_payment_terms( payment_terms_template, - posting_date=entry.target_date, - grand_total=entry.suggested_orders_value, - base_grand_total=entry.suggested_orders_value, + posting_date=posting_date, + grand_total=remaining_value, + base_grand_total=remaining_value, ) - if schedule: - for term in schedule: - base_date = None - payment_term_name = term.get("payment_term") - if payment_term_name: - custom_due_date_type = payment_term_details.get(payment_term_name, {}).get( - "custom_due_date" - ) - if custom_due_date_type == "Order date": - base_date = entry.target_date - elif custom_due_date_type == "Shipment date": - base_date = add_days( - entry.target_date, item_details.get("primary_lead_time") or 0 - ) - elif custom_due_date_type == "Arrival date": - total_lead_time = (item_details.get("primary_lead_time") or 0) + ( - item_details.get("additional_lead_time") or 0 - ) - base_date = add_days(entry.target_date, total_lead_time) - else: - base_date = entry.target_date - if base_date: - term.due_date = get_due_date(term, posting_date=base_date) - due_date = term.get("due_date") - payment_amount = term.get("payment_amount") - if due_date and payment_amount: - year, week, _day = getdate(due_date).isocalendar() - target_name = f"{item_code}-{year}CW{week:02d}" - target_entry = entry_map.get(target_name) - if target_entry: - target_entry.suggested_orders_value_payable = ( - target_entry.suggested_orders_value_payable or 0 - ) + payment_amount - elif getdate(due_date) < mrp_entry_docs[0].target_date: - mrp_entry_docs[0].suggested_orders_value_payable = ( - mrp_entry_docs[0].suggested_orders_value_payable or 0 - ) + payment_amount - else: - for entry in mrp_entry_docs: - entry.suggested_orders_value_payable = entry.suggested_orders_value - - # Calculate Scheduled Receipts Payable using actual PO dates. - # Each open PO line is processed individually so its own transaction_date (order), - # schedule_date (shipment/ETD), and custom_expected_arrival_date (arrival/ETA) can be used - # as the base date for the matching payment term type. - item_po_lines = po_lines_by_item.get(item_code, []) - - if supplier_name and payment_terms_template and item_po_lines: - entry_map = {e.name: e for e in mrp_entry_docs} - for po_line in item_po_lines: - remaining_value = po_line.get("remaining_value") or 0 - if not remaining_value: - continue - - order_date = ( - getdate(po_line.get("transaction_date")) if po_line.get("transaction_date") else None - ) - shipment_date = ( - getdate(po_line.get("schedule_date")) if po_line.get("schedule_date") else None - ) - arrival_date = ( - getdate(po_line.get("custom_expected_arrival_date")) - if po_line.get("custom_expected_arrival_date") - else shipment_date - ) - posting_date = arrival_date or shipment_date or order_date or date.today() - - schedule = get_payment_terms( - payment_terms_template, - posting_date=posting_date, - grand_total=remaining_value, - base_grand_total=remaining_value, - ) - if not schedule: - continue - - for term in schedule: - base_date = None - payment_term_name = term.get("payment_term") - if payment_term_name: - custom_due_date_type = payment_term_details.get(payment_term_name, {}).get( - "custom_due_date" - ) - if custom_due_date_type == "Order date": - base_date = order_date - elif custom_due_date_type == "Shipment date": - base_date = shipment_date - elif custom_due_date_type == "Arrival date": - base_date = arrival_date - else: - base_date = posting_date - - if base_date: - term.due_date = get_due_date(term, posting_date=base_date) - - due_date = term.get("due_date") - payment_amount = term.get("payment_amount") - if due_date and payment_amount: - year, week, _day = getdate(due_date).isocalendar() - target_name = f"{item_code}-{year}CW{week:02d}" - target_entry = entry_map.get(target_name) - if target_entry: - target_entry.scheduled_receipts_value_payable = ( - target_entry.scheduled_receipts_value_payable or 0 - ) + payment_amount - elif getdate(due_date) < mrp_entry_docs[0].target_date: - mrp_entry_docs[0].scheduled_receipts_value_payable = ( - mrp_entry_docs[0].scheduled_receipts_value_payable or 0 - ) + payment_amount - - elif not (supplier_name and payment_terms_template): - for entry in mrp_entry_docs: - entry.scheduled_receipts_value_payable = entry.scheduled_receipts_value + if not schedule: + continue + + for term in schedule: + base_date = None + payment_term_name = term.get("payment_term") + if payment_term_name: + custom_due_date_type = payment_term_details.get(payment_term_name, {}).get( + "custom_due_date" + ) + if custom_due_date_type == "Order date": + base_date = order_date + elif custom_due_date_type == "Shipment date": + base_date = shipment_date + elif custom_due_date_type == "Arrival date": + base_date = arrival_date + else: + base_date = posting_date + + if base_date: + term.due_date = get_due_date(term, posting_date=base_date) + + due_date = term.get("due_date") + payment_amount = term.get("payment_amount") + if due_date and payment_amount: + year, week, _day = getdate(due_date).isocalendar() + target_name = f"{item_code}-{year}CW{week:02d}" + target_entry = entry_map.get(target_name) + if target_entry: + target_entry.scheduled_receipts_value_payable = ( + target_entry.scheduled_receipts_value_payable or 0 + ) + payment_amount + elif getdate(due_date) < mrp_entry_docs[0].target_date: + mrp_entry_docs[0].scheduled_receipts_value_payable = ( + mrp_entry_docs[0].scheduled_receipts_value_payable or 0 + ) + payment_amount + + elif not (supplier_name and payment_terms_template): + for entry in mrp_entry_docs: + entry.scheduled_receipts_value_payable = entry.scheduled_receipts_value for entry in mrp_entry_docs: entry.total_payable = (entry.suggested_orders_value_payable or 0) + ( From 2e6a47b424454625d18d6462b02edf0495ff8f34 Mon Sep 17 00:00:00 2001 From: Dirk van der Laarse Date: Wed, 27 May 2026 06:48:32 +0000 Subject: [PATCH 2/3] chore: bump to v0.8.5 --- erpnext_mrp/__init__.py | 2 +- frontend/package.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext_mrp/__init__.py b/erpnext_mrp/__init__.py index fa3ddd8..af46754 100644 --- a/erpnext_mrp/__init__.py +++ b/erpnext_mrp/__init__.py @@ -1 +1 @@ -__version__ = "0.8.4" +__version__ = "0.8.5" diff --git a/frontend/package.json b/frontend/package.json index 6ab2bfd..f75b6fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "erpnext-mrp-ui", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "dev": "vite --host", diff --git a/package.json b/package.json index ec7f0c2..db68cee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "erpnext_mrp", - "version": "0.8.4", + "version": "0.8.5", "author": "Starktail (Pty) Ltd ", "workspaces": [ "frontend", From 7fd80dee1abbe15c62434cdf2780dac0f9e8dc68 Mon Sep 17 00:00:00 2001 From: Dirk van der Laarse Date: Wed, 27 May 2026 06:59:23 +0000 Subject: [PATCH 3/3] docs: values only calculated for purchased items --- docs/erpnext_mrp_run.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/erpnext_mrp_run.md b/docs/erpnext_mrp_run.md index 0438d71..b5ec7fc 100644 --- a/docs/erpnext_mrp_run.md +++ b/docs/erpnext_mrp_run.md @@ -102,7 +102,7 @@ After all levels have been processed, the system computes the output and action - **Suggested Orders**: Each `Suggested Receipt` is offset backward by the item's lead time to place a `Suggested Order` in the correct earlier period. For example, a suggested receipt in Week 42 for an item with a 2-week lead time generates a suggested order in Week 40. - **Days to Reorder**: Calculated for the header period (week 0) only. The system finds the first period with a suggested receipt, works backward by the item's lead time, and computes how many days remain. A positive value means there is still time to act; a negative value means the order is already late. If no shortage is projected across the entire horizon, this field shows `—` to clearly distinguish "no action needed" from `0` (order today). Two variants are calculated: one including the safety stock floor and one excluding it. -- **Cash Requirements**: Finally, the system projects the financial impact of the plan across three fields. +- **Cash Requirements**: Finally, the system projects the financial impact of the plan for **purchased items only** (items without an active default BOM). Manufactured items are excluded because their cost arises from production, not procurement. - **Suggested Orders Value**: Calculates the estimated cost of the `Suggested Orders` using a three-step price lookup: 1. **Supplier-specific price** — an `Item Price` record where `supplier` matches the item's default supplier, in the currency of the configured buying price list, with a valid date range. 2. **Buying price list** — if no supplier-specific price exists, the most recently valid `Item Price` on the price list configured in **Buying Settings → Buying Price List**, filtered to the correct currency and date range. @@ -146,11 +146,11 @@ The following are the key fields calculated for each item in each period: | `suggested_receipts` | The quantity the MRP calculation suggests should be received in this period to avoid a shortage. | | `suggested_orders` | The quantity that should be ordered, offset by lead time. This is the primary action field. | | `projected_on_hand_inventory` | The projected stock on hand at the end of the period after considering all demand, supply, and suggested receipts. | -| `suggested_orders_value` | The estimated value of the suggested orders. | -| `suggested_orders_value_payable`| Projected cash outflow for not-yet-placed orders, distributed by the supplier's payment terms. | -| `scheduled_receipts_value` | Base-currency value of open Purchase Order lines due in this period: `SUM((qty − received_qty) × base_rate)`. | -| `scheduled_receipts_value_payable` | Projected cash outflow for open Purchase Orders, distributed by the supplier's payment terms using the actual PO dates (`transaction_date`, `schedule_date`, or `custom_expected_arrival_date`) per payment term type. | -| `total_payable` | Combined projected cash outflow: `scheduled_receipts_value_payable + suggested_orders_value_payable`. | +| `suggested_orders_value` | The estimated value of the suggested orders. Only populated for purchased items (items without an active default BOM). | +| `suggested_orders_value_payable`| Projected cash outflow for not-yet-placed orders, distributed by the supplier's payment terms. Only populated for purchased items. | +| `scheduled_receipts_value` | Base-currency value of open Purchase Order lines due in this period: `SUM((qty − received_qty) × base_rate)`. Only populated for purchased items. | +| `scheduled_receipts_value_payable` | Projected cash outflow for open Purchase Orders, distributed by the supplier's payment terms using the actual PO dates (`transaction_date`, `schedule_date`, or `custom_expected_arrival_date`) per payment term type. Only populated for purchased items. | +| `total_payable` | Combined projected cash outflow: `scheduled_receipts_value_payable + suggested_orders_value_payable`. Only populated for purchased items. | | **Urgency Indicators** (header period only) | | | `days_to_reorder` | Days until the order must be placed, accounting for lead time and safety stock. Negative = already late. Blank (`—`) when no shortage is projected across the entire horizon. | | `days_to_reorder_excl_reorder_level` | Same as above, but calculated without the safety stock floor. Blank (`—`) when no true shortage (excluding safety stock) is projected. |