From 354708a54e3e9863d37fffc0868d2b2d5248860e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 29 Jul 2026 15:18:20 +0530 Subject: [PATCH] feat: close individual Delivery Note and Purchase Receipt items Extends row level close to the two documents where the goods have already moved, so closing a row writes off what is left to bill rather than what is left to fulfil. Nothing is released in Bin. Delivery Note and Purchase Receipt are billed through their own services rather than through status_updater, so their invoices declare the row link in `closed_source_links`. Without it a closed row stayed invoiceable, since the existing guard only walked status_updater args. `per_returned` shares the percentage funnel on both doctypes and is excluded from `SETTLED_BY_CLOSE`: closing a row writes off pending billing, it does not turn the row into a return. Closed rows now show a grey indicator in the items grid on all four doctypes. Purchase Receipt had no indicator formatter at all and gets one. --- .../purchase_invoice/purchase_invoice.py | 3 + .../doctype/sales_invoice/sales_invoice.py | 3 + .../doctype/purchase_order/purchase_order.js | 7 +- erpnext/controllers/item_close.py | 11 +- erpnext/controllers/status_updater.py | 31 +++- .../tests/test_item_close_billing.py | 160 ++++++++++++++++++ erpnext/public/js/utils/item_close.js | 12 +- .../doctype/sales_order/sales_order.js | 7 +- .../doctype/delivery_note/delivery_note.js | 35 +++- .../doctype/delivery_note/delivery_note.py | 6 + erpnext/stock/doctype/delivery_note/mapper.py | 23 ++- .../delivery_note/services/billing_status.py | 6 + .../delivery_note_item.json | 12 +- .../delivery_note_item/delivery_note_item.py | 1 + .../stock/doctype/purchase_receipt/mapper.py | 2 +- .../purchase_receipt/purchase_receipt.js | 37 +++- .../purchase_receipt/purchase_receipt.py | 10 ++ .../services/billing_status.py | 2 +- .../purchase_receipt_item.json | 12 +- .../purchase_receipt_item.py | 1 + 20 files changed, 347 insertions(+), 34 deletions(-) create mode 100644 erpnext/controllers/tests/test_item_close_billing.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index fb4836026d6..6ba25382d7c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -235,6 +235,9 @@ class PurchaseInvoice(BuyingController): "overflow_type": "billing", } ] + self.closed_source_links = [ + ("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt") + ] def onload(self): super().onload() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index e2969ec23ce..9c4f06fe8ac 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -273,6 +273,9 @@ class SalesInvoice(SellingController): "overflow_type": "billing", } ] + self.closed_source_links = [ + ("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note") + ] def set_indicator(self): """Set indicator for portal""" diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 9b6a1bb376a..7d0c56d0aff 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -14,7 +14,9 @@ frappe.ui.form.on("Purchase Order", { setup: function (frm) { frm.set_indicator_formatter("item_code", function (doc) { let color; - if (!doc.qty && frm.doc.has_unit_price_items) { + if (doc.closed) { + color = "gray"; + } else if (!doc.qty && frm.doc.has_unit_price_items) { color = "yellow"; } else if (doc.qty <= doc.received_qty) { color = "green"; @@ -283,7 +285,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } this.frm.set_df_property("drop_ship", "hidden", !is_drop_ship); - this.set_item_close_buttons(); if (doc.docstatus == 1) { this.frm.fields_dict.items_section.wrapper.addClass("hide-border"); @@ -438,6 +439,8 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } else if (doc.docstatus === 0) { this.frm.cscript.add_from_mappers(); } + + this.set_item_close_buttons(); } validate() { diff --git a/erpnext/controllers/item_close.py b/erpnext/controllers/item_close.py index 3780edc43e5..911f9e907d5 100644 --- a/erpnext/controllers/item_close.py +++ b/erpnext/controllers/item_close.py @@ -15,7 +15,12 @@ import frappe from frappe import _ from frappe.utils import cint -REOPEN_STATUS = {"Purchase Order": "Submitted", "Sales Order": "Draft"} +REOPEN_STATUS = { + "Purchase Order": "Submitted", + "Sales Order": "Draft", + "Delivery Note": "Submitted", + "Purchase Receipt": "Submitted", +} SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed") @@ -38,9 +43,7 @@ def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: @frappe.whitelist() -def update_closed_status( - doctype: str, name: str, item_names: str | list[str], closed: int -) -> None: +def update_closed_status(doctype: str, name: str, item_names: str | list[str], closed: int) -> None: if not has_closable_items(doctype): frappe.throw(_("Rows of {0} cannot be closed individually").format(_(doctype))) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index dd4260b97c9..8cea29bed61 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -198,23 +198,36 @@ class StatusUpdater(Document): self.update_qty() self.validate_qty() + def get_closed_source_links(self): + """Row links that must not point at a closed source row. + + `status_updater` covers documents whose progress it already tracks. + Delivery Note and Purchase Receipt are billed through their own services + instead, so their invoices declare the link in `closed_source_links`. + """ + links = [ + (args["source_dt"], args["join_field"], args["target_dt"], args["target_parent_dt"]) + for args in self.status_updater + if args.get("target_dt") + and args.get("target_parent_dt") + and has_closable_items(args["target_parent_dt"]) + ] + + return links + list(getattr(self, "closed_source_links", [])) + def validate_closed_source_items(self): """Block submitting against rows that were closed on the source document.""" if self.docstatus != 1: return - for args in self.status_updater: - target_dt = args.get("target_dt") - if not target_dt or not has_closable_items(args.get("target_parent_dt")): - continue - + for source_dt, join_field, target_dt, target_parent_dt in self.get_closed_source_links(): if not frappe.get_meta(target_dt).has_field("closed"): continue row_idx = {} - for d in self.get_all_children(args["source_dt"]): - if d.get(args["join_field"]): - row_idx[d.get(args["join_field"])] = d.idx + for d in self.get_all_children(source_dt): + if d.get(join_field): + row_idx[d.get(join_field)] = d.idx if not row_idx: continue @@ -230,7 +243,7 @@ class StatusUpdater(Document): _("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format( row_idx[row.name], frappe.bold(row.item_code), - _(args.get("target_parent_dt") or target_dt), + _(target_parent_dt), frappe.bold(row.parent), ) ) diff --git a/erpnext/controllers/tests/test_item_close_billing.py b/erpnext/controllers/tests/test_item_close_billing.py new file mode 100644 index 00000000000..d298fe89b27 --- /dev/null +++ b/erpnext/controllers/tests/test_item_close_billing.py @@ -0,0 +1,160 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.controllers.item_close import update_closed_status +from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + +WAREHOUSE = "_Test Warehouse - _TC" + + +class TestPurchaseReceiptItemClose(ERPNextTestSuite): + def setUp(self): + self.first_item = make_item(properties={"is_stock_item": 1}).name + self.second_item = make_item(properties={"is_stock_item": 1}).name + + def make_purchase_receipt(self): + receipt = make_purchase_receipt( + item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True + ) + receipt.append( + "items", + { + "item_code": self.second_item, + "warehouse": WAREHOUSE, + "qty": 10, + "rate": 100, + }, + ) + receipt.save() + receipt.submit() + return receipt + + def close_items(self, doc, rows, closed=1): + update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed) + doc.reload() + + def test_closing_row_settles_billing_percentage(self): + receipt = self.make_purchase_receipt() + self.assertEqual(receipt.per_billed, 0) + + self.close_items(receipt, [receipt.items[1]]) + + self.assertEqual(receipt.per_billed, 50) + + def test_closing_every_row_closes_the_receipt(self): + receipt = self.make_purchase_receipt() + + self.close_items(receipt, receipt.items) + + self.assertEqual(receipt.per_billed, 100) + self.assertEqual(receipt.status, "Closed") + + def test_closed_row_is_not_mapped_to_purchase_invoice(self): + receipt = self.make_purchase_receipt() + self.close_items(receipt, [receipt.items[1]]) + + invoice = make_purchase_invoice(receipt.name) + + self.assertEqual([item.item_code for item in invoice.items], [self.first_item]) + + def test_billing_a_closed_row_is_blocked(self): + receipt = self.make_purchase_receipt() + invoice = make_purchase_invoice(receipt.name) + + self.close_items(receipt, [receipt.items[1]]) + + invoice.insert() + self.assertRaises(frappe.ValidationError, invoice.submit) + + def test_parent_reopen_is_blocked_when_all_rows_are_closed(self): + receipt = self.make_purchase_receipt() + self.close_items(receipt, receipt.items) + + self.assertRaises(frappe.ValidationError, receipt.update_status, "Submitted") + + def test_reopening_one_row_reopens_the_receipt(self): + receipt = self.make_purchase_receipt() + self.close_items(receipt, receipt.items) + + self.close_items(receipt, [receipt.items[1]], closed=0) + + self.assertNotEqual(receipt.status, "Closed") + self.assertEqual(receipt.per_billed, 50) + + +class TestDeliveryNoteItemClose(ERPNextTestSuite): + def setUp(self): + self.first_item = make_item(properties={"is_stock_item": 1}).name + self.second_item = make_item(properties={"is_stock_item": 1}).name + for item_code in (self.first_item, self.second_item): + make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50) + + def make_delivery_note(self): + note = create_delivery_note( + item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_save=True + ) + note.append( + "items", + { + "item_code": self.second_item, + "warehouse": WAREHOUSE, + "qty": 10, + "rate": 100, + }, + ) + note.insert() + note.submit() + return note + + def close_items(self, doc, rows, closed=1): + update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed) + doc.reload() + + def test_closing_row_settles_billing_percentage(self): + note = self.make_delivery_note() + self.assertEqual(note.per_billed, 0) + + self.close_items(note, [note.items[1]]) + + self.assertEqual(note.per_billed, 50) + + def test_closing_every_row_closes_the_note(self): + note = self.make_delivery_note() + + self.close_items(note, note.items) + + self.assertEqual(note.per_billed, 100) + self.assertEqual(note.status, "Closed") + + def test_closed_row_is_not_mapped_to_sales_invoice(self): + note = self.make_delivery_note() + self.close_items(note, [note.items[1]]) + + invoice = make_sales_invoice(note.name) + + self.assertEqual([item.item_code for item in invoice.items], [self.first_item]) + + def test_billing_a_closed_row_is_blocked(self): + note = self.make_delivery_note() + invoice = make_sales_invoice(note.name) + + self.close_items(note, [note.items[1]]) + + invoice.insert() + self.assertRaises(frappe.ValidationError, invoice.submit) + + def test_closing_a_row_does_not_mark_it_returned(self): + note = self.make_delivery_note() + + self.close_items(note, note.items) + + self.assertEqual(note.per_returned, 0) + self.assertEqual(note.status, "Closed") diff --git a/erpnext/public/js/utils/item_close.js b/erpnext/public/js/utils/item_close.js index f66e0d3d636..cf08a76bc6a 100644 --- a/erpnext/public/js/utils/item_close.js +++ b/erpnext/public/js/utils/item_close.js @@ -1,3 +1,5 @@ +frappe.provide("erpnext"); + erpnext.item_close = { add_buttons(frm, config) { if (frm.doc.docstatus != 1 || !frm.has_perm("submit")) { @@ -42,16 +44,14 @@ erpnext.item_close = { cannot_add_rows: true, cannot_delete_rows: true, in_place_edit: false, - fields: [ - { fieldname: "name", fieldtype: "Data", read_only: 1, hidden: 1 }, - ].concat(config.columns), + fields: [{ fieldname: "name", fieldtype: "Data", read_only: 1, hidden: 1 }].concat( + config.columns + ), }, ], primary_action_label: closed ? __("Close") : __("Reopen"), primary_action: () => { - const selected = dialog.fields_dict.items.grid - .get_selected_children() - .map((row) => row.name); + const selected = dialog.fields_dict.items.grid.get_selected_children().map((row) => row.name); if (!selected.length) { frappe.msgprint(__("Select at least one row")); diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index cb8b50d4d1d..46600df4d63 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -25,7 +25,9 @@ frappe.ui.form.on("Sales Order", { // formatter for material request item frm.set_indicator_formatter("item_code", function (doc) { let color; - if (!doc.qty && frm.doc.has_unit_price_items) { + if (doc.closed) { + color = "gray"; + } else if (!doc.qty && frm.doc.has_unit_price_items) { color = "yellow"; } else if (doc.stock_qty - doc.delivered_qty <= doc.actual_qty) { color = "green"; @@ -973,7 +975,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex var me = this; super.refresh(); let allow_delivery = false; - this.set_item_close_buttons(); if (doc.docstatus == 1) { if ( @@ -1266,6 +1267,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } this.order_type(doc); + + this.set_item_close_buttons(); } items_add(doc, cdt, cdn) { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 6c5e1fadf04..d3dec6d4c42 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -23,6 +23,9 @@ frappe.ui.form.on("Delivery Note", { Shipment: "Shipment", }), frm.set_indicator_formatter("item_code", function (doc) { + if (doc.closed) { + return "gray"; + } return doc.docstatus == 1 || doc.qty <= doc.actual_qty ? "green" : "orange"; }); @@ -353,7 +356,12 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( } } - if (doc.docstatus == 1 && doc.status === "Closed" && this.frm.has_perm("submit")) { + if ( + doc.docstatus == 1 && + doc.status === "Closed" && + this.frm.has_perm("submit") && + !doc.items.every((item) => item.closed) + ) { this.frm.add_custom_button( __("Reopen"), function () { @@ -363,6 +371,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( ); } erpnext.stock.delivery_note.set_print_hide(doc, dt, dn); + this.set_item_close_buttons(); } make_shipment() { @@ -429,6 +438,30 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( this.update_status("Submitted"); } + set_item_close_buttons() { + erpnext.item_close.add_buttons(this.frm, { + is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount), + help: __( + "Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Sales Invoice." + ), + summarise: (item) => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + amount: item.amount, + billed_amt: item.billed_amt || 0, + pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0), + }), + columns: [ + erpnext.item_close.column("item_code", __("Item Code"), "Data", 3), + erpnext.item_close.column("item_name", __("Item Name"), "Data", 2), + erpnext.item_close.column("qty", __("Qty")), + erpnext.item_close.column("amount", __("Amount"), "Currency", 2), + erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2), + ], + }); + } + update_status(status) { var me = this; frappe.ui.form.is_saving = true; diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 8717564fde0..2bb6ac70629 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -642,6 +642,12 @@ class DeliveryNote(SellingController): def update_status(self, status): BillingStatusService(self).update_status(status) + def on_item_close_status_change(self): + self.update_billing_percentage() + + def is_item_closable(self, item): + return flt(item.billed_amt) < flt(item.amount) + def update_billing_status(self, update_modified=True): BillingStatusService(self).update_billing_status(update_modified) diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py index 0e565a427f1..1138f0b7079 100644 --- a/erpnext/stock/doctype/delivery_note/mapper.py +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -58,6 +58,14 @@ def get_returned_qty_map(delivery_note: str) -> dict: return returned_qty_map +def is_bundle_of_closed_row(packed_item) -> bool: + """A packed item follows the Delivery Note Item row that bundles it.""" + return bool( + packed_item.parent_detail_docname + and frappe.db.get_value("Delivery Note Item", packed_item.parent_detail_docname, "closed") + ) + + @frappe.whitelist() def make_sales_invoice( source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None @@ -123,7 +131,7 @@ def make_sales_invoice( def select_item(d): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True - return child_filter + return child_filter and not d.closed doc = get_mapped_doc( "Delivery Note", @@ -254,7 +262,7 @@ def make_installation_note( "parenttype": "prevdoc_doctype", }, "postprocess": update_item, - "condition": lambda doc: doc.installed_qty < doc.qty, + "condition": lambda doc: doc.installed_qty < doc.qty and not doc.closed, }, }, target_doc, @@ -293,7 +301,9 @@ def make_packing_slip(source_name: str, target_doc: str | dict | Document | None }, "postprocess": update_item, "condition": lambda item: ( - not is_product_bundle(item.item_code) and flt(item.packed_qty) < flt(item.qty) + not is_product_bundle(item.item_code) + and not item.closed + and flt(item.packed_qty) < flt(item.qty) ), }, "Packed Item": { @@ -307,7 +317,9 @@ def make_packing_slip(source_name: str, target_doc: str | dict | Document | None "name": "pi_detail", }, "postprocess": update_item, - "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)), + "condition": lambda item: ( + flt(item.packed_qty) < flt(item.qty) and not is_bundle_of_closed_row(item) + ), }, }, target_doc, @@ -576,7 +588,8 @@ def make_inter_company_transaction(doctype: str, source_name: str, target_doc=No "Material_request_item": "material_request_item", }, "field_no_map": ["warehouse"], - "condition": lambda item: item.received_qty < item.qty + item.returned_qty, + "condition": lambda item: item.received_qty < item.qty + item.returned_qty + and not item.closed, "postprocess": update_item, }, }, diff --git a/erpnext/stock/doctype/delivery_note/services/billing_status.py b/erpnext/stock/doctype/delivery_note/services/billing_status.py index 70364c89fa7..60a0da23fbb 100644 --- a/erpnext/stock/doctype/delivery_note/services/billing_status.py +++ b/erpnext/stock/doctype/delivery_note/services/billing_status.py @@ -9,6 +9,8 @@ from frappe.desk.notifications import clear_doctype_notifications from frappe.query_builder.functions import Sum from frappe.utils import flt +from erpnext.controllers.item_close import validate_parent_reopen + class BillingStatusService: def __init__(self, doc): @@ -16,6 +18,10 @@ class BillingStatusService: def update_status(self, status: str) -> None: doc = self.doc + + if status != "Closed" and doc.status == "Closed": + validate_parent_reopen(doc) + doc.set_status(update=True, status=status) doc.notify_update() clear_doctype_notifications(doc) diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 5dd6d3d6d5c..b35752bec5b 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -62,6 +62,7 @@ "base_net_rate", "base_net_amount", "billed_amt", + "closed", "incoming_rate", "item_weight_details", "weight_per_unit", @@ -703,6 +704,15 @@ "print_hide": 1, "read_only": 1 }, + { + "default": "0", + "fieldname": "closed", + "fieldtype": "Check", + "label": "Closed", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { "allow_on_submit": 1, "default": "0", @@ -982,7 +992,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-07-29 12:30:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index 62a7691009e..e10e14c2896 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -31,6 +31,7 @@ class DeliveryNoteItem(Document): batch_no: DF.Link | None billed_amt: DF.Currency brand: DF.Link | None + closed: DF.Check company_total_stock: DF.Float conversion_factor: DF.Float cost_center: DF.Link | None diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py index 1a5697b5326..ae862983cce 100644 --- a/erpnext/stock/doctype/purchase_receipt/mapper.py +++ b/erpnext/stock/doctype/purchase_receipt/mapper.py @@ -122,7 +122,7 @@ def make_purchase_invoice( def select_item(d): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True - return child_filter + return child_filter and not d.closed doclist = get_mapped_doc( "Purchase Receipt", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 2fc7a6ca12f..ae7fe9c5bee 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -17,6 +17,10 @@ frappe.ui.form.on("Purchase Receipt", { "Landed Cost Voucher": "Landed Cost Voucher", }; + frm.set_indicator_formatter("item_code", function (doc) { + return doc.closed ? "gray" : "green"; + }); + frm.set_query("wip_composite_asset", "items", function () { return { filters: { asset_type: "Composite Asset", docstatus: 0 }, @@ -275,9 +279,40 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend } } - if (this.frm.doc.docstatus == 1 && this.frm.doc.status === "Closed" && this.frm.has_perm("submit")) { + if ( + this.frm.doc.docstatus == 1 && + this.frm.doc.status === "Closed" && + this.frm.has_perm("submit") && + !this.frm.doc.items.every((item) => item.closed) + ) { cur_frm.add_custom_button(__("Reopen"), this.reopen_purchase_receipt, __("Status")); } + + this.set_item_close_buttons(); + } + + set_item_close_buttons() { + erpnext.item_close.add_buttons(this.frm, { + is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount), + help: __( + "Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Purchase Invoice." + ), + summarise: (item) => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + amount: item.amount, + billed_amt: item.billed_amt || 0, + pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0), + }), + columns: [ + erpnext.item_close.column("item_code", __("Item Code"), "Data", 3), + erpnext.item_close.column("item_name", __("Item Name"), "Data", 2), + erpnext.item_close.column("qty", __("Qty")), + erpnext.item_close.column("amount", __("Amount"), "Currency", 2), + erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2), + ], + }); } make_purchase_invoice() { diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index f1d4fb9cea6..4bc6cdba155 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -11,6 +11,7 @@ from frappe.utils import cint, flt, getdate, nowdate import erpnext from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.item_close import validate_parent_reopen from erpnext.stock.doctype.purchase_receipt.services.billing_status import BillingStatusService from erpnext.stock.doctype.purchase_receipt.services.provisional_accounting import ( ProvisionalAccountingService, @@ -519,10 +520,19 @@ class PurchaseReceipt(BuyingController): ) def update_status(self, status): + if status != "Closed" and self.status == "Closed": + validate_parent_reopen(self) + self.set_status(update=True, status=status) self.notify_update() clear_doctype_notifications(self) + def on_item_close_status_change(self): + self.update_billing_status() + + def is_item_closable(self, item): + return flt(item.billed_amt) < flt(item.amount) + def update_billing_status(self, update_modified=True): BillingStatusService(self).update_billing_status(update_modified) diff --git a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py index 519a6c9fc9e..160d6085945 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py +++ b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py @@ -198,7 +198,7 @@ def update_billing_percentage( total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt total_amount += total_billable_amount - total_billed_amount += abs(flt(item.billed_amt)) + total_billed_amount += total_billable_amount if item.closed else abs(flt(item.billed_amt)) if pr_doc.get("is_return") and not total_amount and total_billed_amount: total_amount = total_billed_amount diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index ce445d75470..2cebac95d93 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -73,6 +73,7 @@ "landed_cost_voucher_amount", "amount_difference_with_purchase_invoice", "billed_amt", + "closed", "warehouse_and_reference", "warehouse", "rejected_warehouse", @@ -645,6 +646,15 @@ "print_hide": 1, "read_only": 1 }, + { + "default": "0", + "fieldname": "closed", + "fieldtype": "Check", + "label": "Closed", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { "allow_on_submit": 1, "fieldname": "landed_cost_voucher_amount", @@ -1144,7 +1154,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-07-16 15:00:00.000000", + "modified": "2026-07-29 12:30:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index e91ee3502f0..a8dca441cbb 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -29,6 +29,7 @@ class PurchaseReceiptItem(Document): batch_no: DF.Link | None billed_amt: DF.Currency brand: DF.Link | None + closed: DF.Check conversion_factor: DF.Float cost_center: DF.Link | None delivery_note_item: DF.Data | None