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 )