From 9e1a1fa59c005f53e998a749a4b52f9c5b840db3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 29 Jul 2026 14:53:58 +0530 Subject: [PATCH] feat: close individual Sales Order items Extends row level close to Sales Order. Closing a row releases its reserved qty from Bin, settles its delivery and billing progress, and skips it when creating a Delivery Note, Sales Invoice, Pick List, Material Request or drop ship Purchase Order. A row holding Stock Reservation Entries cannot be closed. Releasing physical stock is a deliberate act, so the row has to be unreserved first rather than having its reservations cancelled as a side effect. Packed Items have no flag of their own and follow the Sales Order Item row that bundles them, both in the mappers and in the reserved qty rollup. Closed rows count as picked in the picking percentage, so the Pick List button stops offering a list that would map no rows. The close dialog moves to erpnext/public/js/utils/item_close.js and is shared with Purchase Order, with each doctype supplying its own eligibility rule, help text and columns. REOPEN_STATUS now records why the per doctype value matters: Sales Order re-checks the credit limit only on the literal "Draft", so each doctype reuses whatever its own Re-open button passes. --- .../doctype/purchase_order/purchase_order.js | 168 +++--------------- erpnext/controllers/item_close.py | 20 ++- .../tests/test_item_close_sales_order.py | 143 +++++++++++++++ erpnext/public/js/erpnext.bundle.js | 1 + erpnext/public/js/utils/item_close.js | 89 ++++++++++ erpnext/selling/doctype/sales_order/mapper.py | 23 ++- .../doctype/sales_order/sales_order.js | 55 +++++- .../doctype/sales_order/sales_order.py | 16 ++ .../doctype/sales_order/services/status.py | 51 +++++- .../sales_order_item/sales_order_item.json | 12 +- .../sales_order_item/sales_order_item.py | 1 + erpnext/stock/stock_balance.py | 3 + 12 files changed, 414 insertions(+), 168 deletions(-) create mode 100644 erpnext/controllers/tests/test_item_close_sales_order.py create mode 100644 erpnext/public/js/utils/item_close.js diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 13676dcec04..9b6a1bb376a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -703,153 +703,29 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } set_item_close_buttons() { - if (this.frm.doc.docstatus != 1 || !this.frm.has_perm("submit")) { - return; - } - - if (this.frm.doc.status != "Closed" && this.frm.doc.items.some((item) => this.is_item_closable(item))) { - this.frm.add_custom_button( - __("Close Items"), - () => this.select_items_to_close(1), - __("Status") - ); - } - - if (this.frm.doc.items.some((item) => item.closed)) { - this.frm.add_custom_button( - __("Reopen Items"), - () => this.select_items_to_close(0), - __("Status") - ); - } - } - - is_item_closable(item) { - return ( - !item.closed && - (flt(item.received_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount)) - ); - } - - select_items_to_close(closed) { - const rows = this.frm.doc.items - .filter((item) => (closed ? this.is_item_closable(item) : item.closed)) - .map((item) => { - return { - name: item.name, - item_code: item.item_code, - item_name: item.item_name, - qty: item.qty, - received_qty: item.received_qty || 0, - pending_qty: Math.max(flt(item.qty) - flt(item.received_qty), 0), - pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0), - }; - }); - - const dialog = new frappe.ui.Dialog({ - title: closed ? __("Close Items") : __("Reopen Items"), - size: "large", - fields: [ - { - fieldtype: "HTML", - fieldname: "help", - options: closed - ? `

${__( - "Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice." - )}

` - : "", - }, - { - fieldname: "items", - fieldtype: "Table", - data: rows, - cannot_add_rows: true, - cannot_delete_rows: true, - in_place_edit: false, - fields: [ - { fieldname: "name", fieldtype: "Data", read_only: 1, hidden: 1 }, - { - fieldname: "item_code", - fieldtype: "Link", - options: "Item", - label: __("Item Code"), - in_list_view: 1, - read_only: 1, - columns: 3, - }, - { - fieldname: "item_name", - fieldtype: "Data", - label: __("Item Name"), - in_list_view: 1, - read_only: 1, - columns: 2, - }, - { - fieldname: "qty", - fieldtype: "Float", - label: __("Qty"), - in_list_view: 1, - read_only: 1, - columns: 1, - }, - { - fieldname: "received_qty", - fieldtype: "Float", - label: __("Received Qty"), - in_list_view: 1, - read_only: 1, - columns: 1, - }, - { - fieldname: "pending_qty", - fieldtype: "Float", - label: __("Pending Qty"), - in_list_view: 1, - read_only: 1, - columns: 1, - }, - { - fieldname: "pending_amount", - fieldtype: "Currency", - label: __("Pending Amount"), - in_list_view: 1, - read_only: 1, - columns: 2, - }, - ], - }, + erpnext.item_close.add_buttons(this.frm, { + is_closable: (item) => + !item.closed && + (flt(item.received_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount)), + help: __( + "Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice." + ), + summarise: (item) => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + received_qty: item.received_qty || 0, + pending_qty: Math.max(flt(item.qty) - flt(item.received_qty), 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("received_qty", __("Received Qty")), + erpnext.item_close.column("pending_qty", __("Pending Qty")), + erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2), ], - primary_action_label: closed ? __("Close") : __("Reopen"), - primary_action: () => { - const selected = dialog.fields_dict.items.grid - .get_selected_children() - .map((row) => row.name); - - if (!selected.length) { - frappe.msgprint(__("Select at least one row")); - return; - } - - dialog.hide(); - this.update_items_closed_status(selected, closed); - }, - }); - - dialog.show(); - } - - update_items_closed_status(item_names, closed) { - frappe.call({ - method: "erpnext.controllers.item_close.update_closed_status", - args: { - doctype: this.frm.doc.doctype, - name: this.frm.doc.name, - item_names: item_names, - closed: closed, - }, - freeze: true, - callback: () => this.frm.reload_doc(), }); } diff --git a/erpnext/controllers/item_close.py b/erpnext/controllers/item_close.py index c4a0c16d6cc..3780edc43e5 100644 --- a/erpnext/controllers/item_close.py +++ b/erpnext/controllers/item_close.py @@ -3,21 +3,25 @@ """Row level close and reopen for transaction items. -Each closable parent maps to the status it is put back into when a row is -reopened while the parent itself is closed. +`REOPEN_STATUS` holds, per closable parent, the status its own Re-open button +passes to `update_status`. `set_status` recomputes from `status_map` anyway, so +the value is mostly a sentinel for "clear the Closed override" -- but not +always: Sales Order re-checks the credit limit only on the literal "Draft". +Reusing each doctype's own value keeps reopening a row indistinguishable from +reopening the document by hand. """ import frappe from frappe import _ from frappe.utils import cint -CLOSABLE_PARENTS = {"Purchase Order": "Submitted"} +REOPEN_STATUS = {"Purchase Order": "Submitted", "Sales Order": "Draft"} SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed") def has_closable_items(doctype: str | None) -> bool: - return doctype in CLOSABLE_PARENTS + return doctype in REOPEN_STATUS def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: str) -> bool: @@ -57,11 +61,15 @@ def update_closed_status( settled = [row for row in changed if not doc.is_item_closable(row)] if settled: frappe.throw( - _("Row #{0}: {1} is already received and billed in full, so there is nothing to close").format( + _("Row #{0}: {1} is already completed in full, so there is nothing to close").format( settled[0].idx, frappe.bold(settled[0].item_code) ) ) + validate_rows = getattr(doc, "validate_item_close", None) + if validate_rows: + validate_rows(changed) + for row in changed: row.db_set("closed", closed) @@ -92,7 +100,7 @@ def reopen_parent_if_closed(doc) -> None: make reopening a row look like it did nothing. """ if doc.status == "Closed": - doc.update_status(CLOSABLE_PARENTS[doc.doctype]) + doc.update_status(REOPEN_STATUS[doc.doctype]) def validate_parent_reopen(doc) -> None: diff --git a/erpnext/controllers/tests/test_item_close_sales_order.py b/erpnext/controllers/tests/test_item_close_sales_order.py new file mode 100644 index 00000000000..fe233daff84 --- /dev/null +++ b/erpnext/controllers/tests/test_item_close_sales_order.py @@ -0,0 +1,143 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import add_days, flt, nowdate + +from erpnext.controllers.item_close import update_closed_status +from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + +WAREHOUSE = "_Test Warehouse - _TC" + + +def get_reserved_qty(item_code): + return flt( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "reserved_qty") + ) + + +class TestSalesOrderItemClose(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_sales_order(self): + so = make_sales_order( + item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True + ) + so.append( + "items", + { + "item_code": self.second_item, + "warehouse": WAREHOUSE, + "qty": 10, + "rate": 100, + "delivery_date": add_days(nowdate(), 1), + }, + ) + so.save() + so.submit() + return so + + def close_items(self, so, rows, closed=1): + update_closed_status("Sales Order", so.name, [row.name for row in rows], closed) + so.reload() + + def test_closing_row_releases_reserved_qty(self): + so = self.make_sales_order() + self.assertEqual(get_reserved_qty(self.second_item), 10) + + self.close_items(so, [so.items[1]]) + + self.assertEqual(get_reserved_qty(self.second_item), 0) + self.assertEqual(get_reserved_qty(self.first_item), 10) + + def test_closing_row_settles_delivery_percentage(self): + so = self.make_sales_order() + + note = make_delivery_note(so.name) + note.items = [item for item in note.items if item.item_code == self.first_item] + note.insert() + note.submit() + + so.reload() + self.assertEqual(so.per_delivered, 50) + + self.close_items(so, [so.items[1]]) + + self.assertEqual(so.per_delivered, 100) + self.assertEqual(so.delivery_status, "Fully Delivered") + + def test_closing_every_row_closes_the_order(self): + so = self.make_sales_order() + + self.close_items(so, so.items) + + self.assertEqual(so.status, "Closed") + self.assertEqual(get_reserved_qty(self.first_item), 0) + self.assertEqual(get_reserved_qty(self.second_item), 0) + + def test_reopening_one_row_reopens_the_parent(self): + so = self.make_sales_order() + self.close_items(so, so.items) + + self.close_items(so, [so.items[1]], closed=0) + + self.assertNotEqual(so.status, "Closed") + self.assertTrue(so.items[0].closed) + self.assertFalse(so.items[1].closed) + self.assertEqual(get_reserved_qty(self.second_item), 10) + self.assertEqual(get_reserved_qty(self.first_item), 0) + + def test_parent_reopen_is_blocked_when_all_rows_are_closed(self): + so = self.make_sales_order() + self.close_items(so, so.items) + + self.assertRaises(frappe.ValidationError, so.update_status, "Draft") + + so.reload() + self.assertEqual(so.status, "Closed") + + def test_closed_row_is_not_mapped_to_delivery_note(self): + so = self.make_sales_order() + self.close_items(so, [so.items[1]]) + + note = make_delivery_note(so.name) + + self.assertEqual([item.item_code for item in note.items], [self.first_item]) + + def test_closed_row_is_not_mapped_to_sales_invoice(self): + so = self.make_sales_order() + self.close_items(so, [so.items[1]]) + + invoice = make_sales_invoice(so.name) + + self.assertEqual([item.item_code for item in invoice.items], [self.first_item]) + + def test_delivering_a_closed_row_is_blocked(self): + so = self.make_sales_order() + note = make_delivery_note(so.name) + + self.close_items(so, [so.items[1]]) + + note.insert() + self.assertRaises(frappe.ValidationError, note.submit) + + def test_settled_row_cannot_be_closed(self): + so = self.make_sales_order() + + note = make_delivery_note(so.name) + note.insert() + note.submit() + invoice = make_sales_invoice(so.name) + invoice.insert() + invoice.submit() + + so.reload() + self.assertRaises(frappe.ValidationError, self.close_items, so, [so.items[0]]) diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index aac34672fde..372c457436b 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -26,6 +26,7 @@ import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; import "./utils/ledger_preview.js"; import "./utils/unreconcile.js"; +import "./utils/item_close"; import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; diff --git a/erpnext/public/js/utils/item_close.js b/erpnext/public/js/utils/item_close.js new file mode 100644 index 00000000000..f66e0d3d636 --- /dev/null +++ b/erpnext/public/js/utils/item_close.js @@ -0,0 +1,89 @@ +erpnext.item_close = { + add_buttons(frm, config) { + if (frm.doc.docstatus != 1 || !frm.has_perm("submit")) { + return; + } + + if (frm.doc.status != "Closed" && frm.doc.items.some((item) => config.is_closable(item))) { + frm.add_custom_button( + __("Close Items"), + () => erpnext.item_close.select_rows(frm, config, 1), + __("Status") + ); + } + + if (frm.doc.items.some((item) => item.closed)) { + frm.add_custom_button( + __("Reopen Items"), + () => erpnext.item_close.select_rows(frm, config, 0), + __("Status") + ); + } + }, + + select_rows(frm, config, closed) { + const rows = frm.doc.items + .filter((item) => (closed ? config.is_closable(item) : item.closed)) + .map((item) => Object.assign({ name: item.name }, config.summarise(item))); + + const dialog = new frappe.ui.Dialog({ + title: closed ? __("Close Items") : __("Reopen Items"), + size: "large", + fields: [ + { + fieldtype: "HTML", + fieldname: "help", + options: closed ? `

${config.help}

` : "", + }, + { + fieldname: "items", + fieldtype: "Table", + data: rows, + 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), + }, + ], + primary_action_label: closed ? __("Close") : __("Reopen"), + primary_action: () => { + const selected = dialog.fields_dict.items.grid + .get_selected_children() + .map((row) => row.name); + + if (!selected.length) { + frappe.msgprint(__("Select at least one row")); + return; + } + + dialog.hide(); + frappe.call({ + method: "erpnext.controllers.item_close.update_closed_status", + args: { + doctype: frm.doc.doctype, + name: frm.doc.name, + item_names: selected, + closed: closed, + }, + freeze: true, + callback: () => frm.reload_doc(), + }); + }, + }); + + dialog.show(); + }, + + column(fieldname, label, fieldtype = "Float", columns = 1) { + return { + fieldname: fieldname, + fieldtype: fieldtype, + label: label, + in_list_view: 1, + read_only: 1, + columns: columns, + }; + }, +}; diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index 8c28e9672b9..d918686d0ab 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -48,6 +48,14 @@ def get_requested_item_qty(sales_order: str) -> dict: return result +def is_bundle_of_closed_row(packed_item) -> bool: + """A packed item follows the Sales Order Item row that bundles it.""" + return bool( + packed_item.parent_detail_docname + and frappe.db.get_value("Sales Order Item", packed_item.parent_detail_docname, "closed") + ) + + @frappe.whitelist() def make_material_request(source_name: str, target_doc: str | dict | Document | None = None): requested_item_qty = get_requested_item_qty(source_name) @@ -130,7 +138,8 @@ def make_material_request(source_name: str, target_doc: str | dict | Document | "Packed Item": { "doctype": "Material Request Item", "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"}, - "condition": lambda item: get_remaining_packed_item_qty(item) > 0, + "condition": lambda item: get_remaining_packed_item_qty(item) > 0 + and not is_bundle_of_closed_row(item), "postprocess": update_item, }, "Sales Order Item": { @@ -142,6 +151,7 @@ def make_material_request(source_name: str, target_doc: str | dict | Document | "bom_no": "bom_no", }, "condition": lambda item: not is_product_bundle(item.item_code) + and not item.closed and get_remaining_qty(item) > 0, "postprocess": update_item, }, @@ -335,7 +345,7 @@ def make_delivery_note( "name": "so_detail", "parent": "against_sales_order", }, - "condition": lambda d: condition(d) and select_item(d), + "condition": lambda d: condition(d) and not d.closed and select_item(d), "postprocess": update_item, } @@ -580,6 +590,7 @@ def make_sales_invoice( if is_unit_price_row(doc) else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) ) + and not doc.closed and select_item(doc) and not args.get("skip_item_mapping"), }, @@ -788,7 +799,7 @@ def make_purchase_order( "margin_rate_or_amount", ], "postprocess": update_item, - "condition": lambda doc, s=supplier: filter_items(doc, s), + "condition": lambda doc, s=supplier: not doc.closed and filter_items(doc, s), }, "Packed Item": { "doctype": "Purchase Order Item", @@ -810,7 +821,8 @@ def make_purchase_order( ], "postprocess": update_item_for_packed_item, "condition": lambda doc: doc.parent_item in item_codes - and flt(doc.ordered_qty) < flt(doc.qty), + and flt(doc.ordered_qty) < flt(doc.qty) + and not is_bundle_of_closed_row(doc), }, }, target_doc, @@ -1010,6 +1022,7 @@ def create_pick_list(source_name: str, target_doc: str | dict | Document | None return ( abs(item.delivered_qty) < abs(item.qty) and item.delivered_by_supplier != 1 + and not item.closed and not is_product_bundle(item.item_code) ) @@ -1112,7 +1125,7 @@ def get_mapped_subcontracting_inward_order( "name": "sales_order_item", }, "field_no_map": ["qty", "fg_item_qty", "amount"], - "condition": lambda item: item.qty != item.subcontracted_qty, + "condition": lambda item: item.qty != item.subcontracted_qty and not item.closed, }, }, target_doc, diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 61a79027a33..cb8b50d4d1d 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -973,6 +973,7 @@ 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 ( @@ -1008,19 +1009,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } } else if (doc.status === "Closed") { // un-close - this.frm.add_custom_button( - __("Re-open"), - function () { - me.frm.cscript.update_status("Re-open", "Draft"); - }, - __("Status") - ); + if (!doc.items.every((item) => item.closed)) { + this.frm.add_custom_button( + __("Re-open"), + function () { + me.frm.cscript.update_status("Re-open", "Draft"); + }, + __("Status") + ); + } } } if (doc.status !== "Closed") { if (doc.status !== "On Hold") { const items_are_deliverable = this.frm.doc.items.some( - (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) + (item) => + item.delivered_by_supplier === 0 && + !item.closed && + item.qty > flt(item.delivered_qty) ); allow_delivery = (this.frm.doc.has_unit_price_items || items_are_deliverable) && @@ -1044,7 +1050,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } if (doc.is_subcontracted) { - if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) { + if ( + !doc.items + .filter((item) => !item.closed) + .every((item) => item.qty == item.subcontracted_qty) + ) { this.frm.add_custom_button( __("Subcontracting Inward Order"), () => { @@ -1871,6 +1881,33 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex close_sales_order() { this.frm.cscript.update_status("Close", "Closed"); } + + set_item_close_buttons() { + erpnext.item_close.add_buttons(this.frm, { + is_closable: (item) => + !item.closed && + (flt(item.delivered_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount)), + help: __( + "Closed rows stop being expected. Their pending quantity is written off, stock is no longer reserved for them, and they are skipped when creating a Delivery Note or Sales Invoice." + ), + summarise: (item) => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + delivered_qty: item.delivered_qty || 0, + pending_qty: Math.max(flt(item.qty) - flt(item.delivered_qty), 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("delivered_qty", __("Delivered Qty")), + erpnext.item_close.column("pending_qty", __("Pending Qty")), + erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2), + ], + }); + } update_status(label, status) { var doc = this.frm.doc; var me = this; diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 784feff8ed7..2911403c496 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -541,6 +541,22 @@ class SalesOrder(SellingController): def update_status(self, status): StatusService(self).update_status(status) + def on_item_close_status_change(self): + StatusService(self).recalculate_after_item_close() + + def is_item_closable(self, item): + return flt(item.delivered_qty) < flt(item.qty) or flt(item.billed_amt) < flt(item.amount) + + def validate_item_close(self, items): + """Reserved stock has to be released deliberately before a row is closed.""" + for item in items: + if has_reserved_stock(self.doctype, self.name, item.name): + frappe.throw( + _("Row #{0}: {1} has reserved stock. Unreserve it before closing the row.").format( + item.idx, frappe.bold(item.item_code) + ) + ) + def update_reserved_qty(self, so_item_rows=None): SalesOrderStockReservation(self).update_reserved_qty(so_item_rows) diff --git a/erpnext/selling/doctype/sales_order/services/status.py b/erpnext/selling/doctype/sales_order/services/status.py index c11163088c8..142cfe01adb 100644 --- a/erpnext/selling/doctype/sales_order/services/status.py +++ b/erpnext/selling/doctype/sales_order/services/status.py @@ -8,6 +8,7 @@ from frappe import _ from frappe.desk.notifications import clear_doctype_notifications from frappe.utils import cint, cstr, flt +from erpnext.controllers.item_close import validate_parent_reopen from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService @@ -27,6 +28,10 @@ class StatusService: def update_status(self, status: str) -> None: doc = self.doc self.check_modified_date() + + if status != "Closed" and doc.status == "Closed": + validate_parent_reopen(doc) + doc.set_status(update=True, status=status) # Upon Sales Order Re-open, check for credit limit. # Limit should be checked after the 'Hold/Closed' status is reset. @@ -38,6 +43,48 @@ class StatusService: clear_doctype_notifications(doc) doc.update_blanket_order() + def recalculate_after_item_close(self) -> None: + """Refresh progress after row flags changed. + + Billing runs last because it reloads the parent and writes the final + status from both percentages. + """ + doc = self.doc + doc.update_reserved_qty() + self.update_picking_status() + self.update_delivery_percentage() + self.update_billing_percentage() + + def update_delivery_percentage(self, update_modified: bool = True) -> None: + self.doc._update_percent_field( + { + "target_dt": "Sales Order Item", + "target_parent_dt": "Sales Order", + "target_parent_field": "per_delivered", + "target_ref_field": "qty", + "target_field": "delivered_qty", + "status_field": "delivery_status", + "keyword": "Delivered", + "name": self.doc.name, + }, + update_modified, + ) + + def update_billing_percentage(self, update_modified: bool = True) -> None: + self.doc._update_percent_field( + { + "target_dt": "Sales Order Item", + "target_parent_dt": "Sales Order", + "target_parent_field": "per_billed", + "target_ref_field": "amount", + "target_field": "billed_amt", + "status_field": "billing_status", + "keyword": "Billed", + "name": self.doc.name, + }, + update_modified, + ) + def check_modified_date(self) -> None: doc = self.doc mod_db = frappe.db.get_value("Sales Order", doc.name, "modified") @@ -75,7 +122,9 @@ class StatusService: if cint( frappe.get_cached_value("Item", so_item.item_code, "is_stock_item") ) or doc.has_product_bundle(so_item.item_code): - total_picked_qty += flt(so_item.picked_qty) + total_picked_qty += ( + flt(so_item.stock_qty) if so_item.closed else flt(so_item.picked_qty) + ) total_qty += flt(so_item.stock_qty) if total_picked_qty and total_qty: diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index df5d4b76617..e6eecdbe120 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -66,6 +66,7 @@ "base_net_rate", "base_net_amount", "billed_amt", + "closed", "valuation_rate", "gross_profit", "drop_ship_section", @@ -617,6 +618,15 @@ "print_hide": 1, "read_only": 1 }, + { + "default": "0", + "fieldname": "closed", + "fieldtype": "Check", + "label": "Closed", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { "fieldname": "valuation_rate", "fieldtype": "Currency", @@ -1055,7 +1065,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-08 20:00:00.000000", + "modified": "2026-07-29 12:05:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 98298f22036..3e3870971d4 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -30,6 +30,7 @@ class SalesOrderItem(Document): blanket_order_rate: DF.Currency bom_no: DF.Link | None 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/stock_balance.py b/erpnext/stock/stock_balance.py index 0e552318faa..eb5dabf51aa 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -96,6 +96,7 @@ def get_reserved_qty(item_code, warehouse): open_so = (so.docstatus == 1) & so.status.notin(["On Hold", "Closed"]) not_delivered_by_supplier = so_item.delivered_by_supplier.isnull() | (so_item.delivered_by_supplier == 0) + not_closed = so_item.closed.isnull() | (so_item.closed == 0) # Keep the reserved-qty rollup in the DB (one aggregate per branch) instead of streaming # every open packed-item / SO-item row into Python. `qty <> 0` mirrors the original @@ -122,6 +123,7 @@ def get_reserved_qty(item_code, warehouse): & (packed_item.parenttype == "Sales Order") & (packed_item.item_code != packed_item.parent_item) & not_delivered_by_supplier + & not_closed & open_so & reservable ) @@ -138,6 +140,7 @@ def get_reserved_qty(item_code, warehouse): (so_item.item_code == item_code) & (so_item.warehouse == warehouse) & not_delivered_by_supplier + & not_closed & open_so & reservable )