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