diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index bceffd3627d..15e3ad06860 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -729,6 +729,7 @@ def get_ordered_amount(params): (child.item_code == item_code) & (parent.docstatus == 1) & (child.amount > child.billed_amt) + & (child.closed == 0) & (parent.status != "Closed") & Criterion.all(get_other_condition(params, child, parent, "Purchase Order")) ) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 0d39d741898..bf8201dc13e 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 d5263e83622..470588fd77e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -278,6 +278,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/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py index 1ac127645c5..3fb3c7b7311 100644 --- a/erpnext/buying/doctype/purchase_order/mapper.py +++ b/erpnext/buying/doctype/purchase_order/mapper.py @@ -89,6 +89,7 @@ def make_purchase_receipt( else abs(doc.received_qty) < abs(get_max_receivable_qty(doc)) ) and doc.delivered_by_supplier != 1 + and not doc.closed and select_item(doc), }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, @@ -193,6 +194,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions or abs(doc.billed_amt) < abs(doc.amount) or doc.qty > flt(get_billed_qty(doc.name)) ) + and not doc.closed and select_item(doc), }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 0b78b2765a9..e8dcea1883e 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"; @@ -340,7 +342,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( this.frm.page.set_inner_btn_group_as_primary(__("Status")); } } else if (["Closed", "Delivered"].includes(doc.status)) { - if (this.frm.has_perm("submit")) { + if (this.frm.has_perm("submit") && !doc.items.every((item) => item.closed)) { this.frm.add_custom_button( __("Re-open"), () => this.unclose_purchase_order(), @@ -352,7 +354,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( if (doc.status != "On Hold") { if ( (doc.items - .filter((item) => !item.delivered_by_supplier) + .filter((item) => !item.delivered_by_supplier && !item.closed) .some((item) => item.received_qty < item.qty) || doc.__onload?.has_pending_receivable_qty) && allow_receipt @@ -365,7 +367,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( __("Create") ); 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 Order"), () => { @@ -433,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() { @@ -697,6 +705,19 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( this.frm.cscript.update_status("Close", "Closed"); } + set_item_close_buttons() { + erpnext.item_close.add_buttons( + this.frm, + erpnext.item_close.fulfilment_config({ + qty_field: "received_qty", + qty_label: __("Received Qty"), + help: __( + "Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice." + ), + }) + ); + } + update_dropship_delivered_qty() { const data = this.frm.doc.items .filter((item) => item.delivered_by_supplier == 1) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index a27689d6052..70382b8bb15 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -402,6 +402,12 @@ class PurchaseOrder(BuyingController): 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.received_qty) < flt(item.qty) or super().is_item_closable(item) + def on_submit(self): super().on_submit() @@ -531,7 +537,7 @@ class PurchaseOrder(BuyingController): considering the configured over_delivery_receipt_allowance. """ for item in self.get("items", []): - if item.delivered_by_supplier: + if item.delivered_by_supplier or item.closed: continue tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0]) max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100 diff --git a/erpnext/buying/doctype/purchase_order/services/status.py b/erpnext/buying/doctype/purchase_order/services/status.py index 85dbd5435cc..7f7ab402eb3 100644 --- a/erpnext/buying/doctype/purchase_order/services/status.py +++ b/erpnext/buying/doctype/purchase_order/services/status.py @@ -9,6 +9,7 @@ from frappe.desk.notifications import clear_doctype_notifications from frappe.utils import cstr, flt from erpnext.buying.doctype.purchase_order.services.subcontracting import SubcontractingService +from erpnext.controllers.item_close import validate_parent_reopen class StatusService: @@ -18,6 +19,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) doc.update_requested_qty() doc.update_ordered_qty() @@ -26,6 +31,17 @@ class StatusService: doc.notify_update() clear_doctype_notifications(doc) + def recalculate_after_item_close(self) -> None: + """Refresh progress after row flags changed. + + `update_billing_percentage` runs last because it reloads the parent and + writes the final status from both percentages. + """ + doc = self.doc + self.update_receiving_percentage() + doc.update_ordered_qty() + doc.update_billing_percentage() + def check_modified_date(self) -> None: doc = self.doc modified_in_db = frappe.db.get_value("Purchase Order", doc.name, "modified") @@ -39,10 +55,9 @@ class StatusService: def update_receiving_percentage(self) -> None: doc = self.doc total_qty, received_qty = 0.0, 0.0 - for item in doc.items: + for item in [item for item in doc.items if not item.closed] or doc.items: received_qty += min(item.received_qty, item.qty) total_qty += item.qty - if total_qty and received_qty: - doc.db_set("per_received", flt(received_qty / total_qty) * 100, update_modified=False) - else: - doc.db_set("per_received", 0, update_modified=False) + + per_received = flt(received_qty / total_qty) * 100 if total_qty else 0 + doc.db_set("per_received", per_received, update_modified=False) diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 62b156959bb..8429b7b2c20 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -86,6 +86,7 @@ "returned_qty", "column_break_60", "billed_amt", + "closed", "accounting_details", "expense_account", "column_break_fyqr", @@ -646,6 +647,15 @@ "print_hide": 1, "read_only": 1 }, + { + "default": "0", + "fieldname": "closed", + "fieldtype": "Check", + "label": "Closed", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { "description": "Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges", "fieldname": "item_tax_rate", @@ -945,7 +955,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-08-27 10:55:37.000000", + "modified": "2026-08-27 11:55:37.000000", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py index b8741486efc..e0878b0f2d5 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py @@ -29,6 +29,7 @@ class PurchaseOrderItem(Document): blanket_order_rate: DF.Currency bom: 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/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4f69a02cd5e..1a54b625559 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -39,6 +39,7 @@ from erpnext.accounts.utils import ( get_advance_payment_doctypes as _get_advance_payment_doctypes, ) from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year +from erpnext.controllers.item_close import clear_closed_rows_on_amend from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, set_print_templates_for_taxes, @@ -227,7 +228,23 @@ class AccountsController(TransactionBase): return False + def is_item_closable(self, item): + """A row can be closed while anything is still pending on it. + + Billing is the axis every closable document shares; the order doctypes + extend this with their own fulfilment axis. + + Amounts are compared as magnitudes so that return rows stay closable. + That is deliberate: writing off a credit note that will never be issued + is a real decision, and closing a whole return document is already + allowed. Leaving it to the sign of the amount would decide it by + accident. + """ + return abs(flt(item.billed_amt)) < abs(flt(item.amount)) + def validate(self): + clear_closed_rows_on_amend(self) + if not self.get("is_return") and not self.get("is_debit_note"): self.validate_qty_is_not_zero() diff --git a/erpnext/controllers/item_close.py b/erpnext/controllers/item_close.py new file mode 100644 index 00000000000..13a9a40623e --- /dev/null +++ b/erpnext/controllers/item_close.py @@ -0,0 +1,145 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Row level close and reopen for transaction items. + +`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 + +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") + + +def has_closable_items(doctype: str | None) -> bool: + return doctype in REOPEN_STATUS + + +def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: str) -> bool: + """Whether closed rows count as fully settled for this progress field. + + Returns are excluded: closing a row writes off what is still pending on it, + it does not turn the row into a return. + """ + return ( + percentage_field in SETTLED_BY_CLOSE + and has_closable_items(parent_doctype) + and frappe.get_meta(item_doctype).has_field("closed") + ) + + +@frappe.whitelist() +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))) + + closed = 1 if cint(closed) else 0 + item_names = set(frappe.parse_json(item_names) or []) + if not item_names: + frappe.throw(_("Select at least one row")) + + doc = frappe.get_lazy_doc(doctype, name, check_permission="submit") + if doc.docstatus != 1: + frappe.throw(_("{0} {1} is not submitted").format(_(doctype), name)) + + changed = [row for row in doc.items if row.name in item_names and cint(row.closed) != closed] + if not changed: + return + + if closed: + settled = [row for row in changed if not doc.is_item_closable(row)] + if settled: + frappe.throw( + _("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) + + doc.on_item_close_status_change() + doc.reload() + + if closed: + close_parent_if_fully_closed(doc) + else: + reopen_parent_if_closed(doc) + + doc.notify_update() + + +def close_parent_if_fully_closed(doc) -> None: + """Close the parent once every row has been closed.""" + if doc.status == "Closed": + return + + if all(cint(row.closed) for row in doc.items): + doc.update_status("Closed") + + +def reopen_parent_if_closed(doc) -> None: + """Reopen the parent so the row that was just reopened can be acted on. + + A closed parent suppresses its rows everywhere, so leaving it closed would + make reopening a row look like it did nothing. + """ + if doc.status == "Closed": + doc.update_status(REOPEN_STATUS[doc.doctype]) + + +def is_bundle_of_closed_row(packed_item) -> bool: + """A packed item follows the row of its parent document that bundles it.""" + if not packed_item.parent_detail_docname or not packed_item.parenttype: + return False + + item_doctype = f"{packed_item.parenttype} Item" + + return bool(frappe.db.get_value(item_doctype, packed_item.parent_detail_docname, "closed")) + + +def clear_closed_rows_on_amend(doc) -> None: + """An amended document starts with nothing written off. + + Frappe copies `no_copy` fields when amending so a cancelled document can be + corrected and resubmitted, which would otherwise carry a write-off decision + that was made against the cancelled document onto the new one. + """ + if not doc.is_new() or not doc.get("amended_from") or not has_closable_items(doc.doctype): + return + + for row in doc.get("items") or []: + row.closed = 0 + + +def validate_parent_reopen(doc) -> None: + """Block reopening a parent whose rows are all closed. + + It would read as open while every row stayed suppressed. Reopening the rows + is the way back, and that reopens the parent on its own. + """ + rows = doc.get("items") or [] + if rows and all(cint(row.get("closed")) for row in rows): + frappe.throw( + _("Every row of {0} is closed. Reopen the rows you need instead, using {1}.").format( + frappe.bold(doc.name), frappe.bold(_("Reopen Items")) + ) + ) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index df7a3482ec5..f487c56cc34 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -8,6 +8,8 @@ from frappe.model.document import Document from frappe.query_builder.functions import Sum from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div +from erpnext.controllers.item_close import closed_rows_settle, has_closable_items + class OverAllowanceError(frappe.ValidationError): pass @@ -192,9 +194,60 @@ class StatusUpdater(Document): self.db_set("status", "Cancelled") def update_prevdoc_status(self): + self.validate_closed_source_items() 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 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(source_dt): + if d.get(join_field): + row_idx[d.get(join_field)] = d.idx + + if not row_idx: + continue + + closed_rows = frappe.get_all( + target_dt, + filters={"name": ("in", list(row_idx)), "closed": 1}, + fields=["name", "item_code", "parent"], + ) + + for row in closed_rows: + frappe.throw( + _("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format( + row_idx[row.name], + frappe.bold(row.item_code), + _(target_parent_dt), + frappe.bold(row.parent), + ) + ) + def set_status(self, update=False, status=None, update_modified=True): if self.is_new(): if self.get("amended_from"): @@ -605,16 +658,28 @@ class StatusUpdater(Document): @staticmethod def _calculate_target_parent_percentage( - name, target_parent_dt, target_dt, target_ref_field, target_field, exclude_field=None + name, + target_parent_dt, + target_dt, + target_ref_field, + target_field, + target_parent_field=None, + exclude_field=None, ): filters = {"parent": name, "parenttype": target_parent_dt} if exclude_field: filters[exclude_field] = 0 + tracks_closed_rows = closed_rows_settle(target_parent_dt, target_dt, target_parent_field) + + fields = [target_ref_field, target_field] + if tracks_closed_rows: + fields.append("closed") + child_records = frappe.get_all( target_dt, filters=filters, - fields=[target_ref_field, target_field], + fields=fields, ) if exclude_field and not child_records: @@ -623,13 +688,19 @@ class StatusUpdater(Document): # For operator dicts, the alias is in the "as" key; for strings, use the field name directly ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field - sum_ref = sum(abs(record[ref_key]) for record in child_records) + # A closed row is written off, so it leaves the denominator rather than + # counting as done. The percentage stays a true measure of what was + # actually received, delivered or billed against what is still expected. + # Once every row is written off there is nothing left to measure against, + # so fall back to the whole table and report what actually happened. + open_records = [r for r in child_records if not (tracks_closed_rows and r["closed"])] + basis = open_records or child_records + + sum_ref = sum(abs(record[ref_key]) for record in basis) if sum_ref > 0: percentage = round( - sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records) - / sum_ref - * 100, + sum(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100, 6, ) else: @@ -678,6 +749,7 @@ class StatusUpdater(Document): args["target_dt"], args["target_ref_field"], args["target_field"], + args["target_parent_field"], args.get("exclude_field"), ) # update field diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 1d54d3b9679..5db865bf514 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -337,8 +337,12 @@ class StockController(AccountsController): items = frappe.get_all( "Delivery Note Item", filters={"parent": self.name, "parenttype": "Delivery Note"}, - fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"], + fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt", "closed"], ) + # A written off row leaves the basis. Once every row is written off there is + # nothing left to measure against, so fall back to the whole table. + items = [item for item in items if not item.closed] or items + total_amount = sum(flt(item.amount) for item in items) total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items) # Preserve the original amount basis once the entire Delivery Note is returned. diff --git a/erpnext/controllers/tests/test_item_close.py b/erpnext/controllers/tests/test_item_close.py new file mode 100644 index 00000000000..821b3b74112 --- /dev/null +++ b/erpnext/controllers/tests/test_item_close.py @@ -0,0 +1,231 @@ +# 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.buying.doctype.purchase_order.mapper import ( + get_mapped_purchase_invoice, + make_purchase_receipt, +) +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.controllers.item_close import update_closed_status +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestSuite + +WAREHOUSE = "_Test Warehouse - _TC" + + +def get_ordered_qty(item_code): + return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "ordered_qty")) + + +class TestPurchaseOrderItemClose(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_order(self): + po = create_purchase_order(item_code=self.first_item, qty=10, rate=100, do_not_save=True) + po.append( + "items", + { + "item_code": self.second_item, + "warehouse": WAREHOUSE, + "qty": 10, + "rate": 100, + "schedule_date": add_days(nowdate(), 1), + }, + ) + po.set_missing_values() + po.insert() + po.submit() + return po + + def close_items(self, po, rows, closed=1): + update_closed_status("Purchase Order", po.name, [row.name for row in rows], closed) + po.reload() + + def test_closing_row_releases_ordered_qty(self): + po = self.make_purchase_order() + self.assertEqual(get_ordered_qty(self.second_item), 10) + + self.close_items(po, [po.items[1]]) + + self.assertEqual(get_ordered_qty(self.second_item), 0) + self.assertEqual(get_ordered_qty(self.first_item), 10) + + def test_closing_row_settles_receiving_percentage(self): + po = self.make_purchase_order() + + receipt = make_purchase_receipt(po.name) + receipt.items = [item for item in receipt.items if item.item_code == self.first_item] + receipt.insert() + receipt.submit() + + po.reload() + self.assertEqual(po.per_received, 50) + self.assertEqual(po.status, "To Receive and Bill") + + self.close_items(po, [po.items[1]]) + + self.assertEqual(po.per_received, 100) + self.assertEqual(po.status, "To Bill") + + def test_closing_every_row_closes_the_order(self): + po = self.make_purchase_order() + + self.close_items(po, po.items) + + self.assertEqual(po.status, "Closed") + self.assertEqual(get_ordered_qty(self.first_item), 0) + self.assertEqual(get_ordered_qty(self.second_item), 0) + + def test_parent_reopen_is_blocked_when_all_rows_are_closed(self): + po = self.make_purchase_order() + self.close_items(po, po.items) + + self.assertRaises(frappe.ValidationError, po.update_status, "Submitted") + + po.reload() + self.assertEqual(po.status, "Closed") + self.assertTrue(all(row.closed for row in po.items)) + + def test_reopening_all_rows_restores_the_order(self): + po = self.make_purchase_order() + self.close_items(po, po.items) + self.assertEqual(po.status, "Closed") + + self.close_items(po, po.items, closed=0) + + self.assertFalse(any(row.closed for row in po.items)) + self.assertEqual(po.per_received, 0) + self.assertEqual(po.status, "To Receive and Bill") + self.assertEqual(get_ordered_qty(self.first_item), 10) + + def test_reopening_one_row_reopens_the_parent(self): + po = self.make_purchase_order() + self.close_items(po, po.items) + + self.close_items(po, [po.items[1]], closed=0) + + self.assertEqual(po.status, "To Receive and Bill") + self.assertTrue(po.items[0].closed) + self.assertFalse(po.items[1].closed) + # nothing received, and the closed row is written off rather than counted + self.assertEqual(po.per_received, 0) + self.assertEqual(get_ordered_qty(self.second_item), 10) + self.assertEqual(get_ordered_qty(self.first_item), 0) + + def test_settled_row_cannot_be_closed(self): + po = self.make_purchase_order() + + receipt = make_purchase_receipt(po.name) + receipt.insert() + receipt.submit() + invoice = get_mapped_purchase_invoice(po.name) + invoice.insert() + invoice.submit() + + po.reload() + self.assertEqual(po.status, "Completed") + self.assertRaises(frappe.ValidationError, self.close_items, po, [po.items[0]]) + + def test_received_but_unbilled_row_can_be_closed(self): + po = self.make_purchase_order() + + receipt = make_purchase_receipt(po.name) + receipt.insert() + receipt.submit() + + po.reload() + self.assertEqual(po.status, "To Bill") + + self.close_items(po, po.items) + + # billing written off, but the goods really did arrive + self.assertEqual(po.per_billed, 0) + self.assertEqual(po.per_received, 100) + self.assertEqual(po.status, "Closed") + + def test_receipt_is_not_offered_when_the_rest_is_closed(self): + po = self.make_purchase_order() + + receipt = make_purchase_receipt(po.name) + receipt.items = [item for item in receipt.items if item.item_code == self.first_item] + receipt.insert() + receipt.submit() + + po.reload() + self.close_items(po, [po.items[1]]) + + self.assertEqual(po.status, "To Bill") + self.assertFalse(po.has_pending_receivable_qty()) + self.assertFalse(make_purchase_receipt(po.name).get("items")) + + def test_reopening_partly_closed_order_keeps_row_flags(self): + po = self.make_purchase_order() + self.close_items(po, [po.items[1]]) + + po.update_status("Closed") + po.reload() + self.assertEqual(po.status, "Closed") + + po.update_status("Submitted") + po.reload() + + self.assertFalse(po.items[0].closed) + self.assertTrue(po.items[1].closed) + self.assertEqual(get_ordered_qty(self.first_item), 10) + self.assertEqual(get_ordered_qty(self.second_item), 0) + + def test_closed_row_is_not_mapped_to_purchase_receipt(self): + po = self.make_purchase_order() + self.close_items(po, [po.items[1]]) + + receipt = make_purchase_receipt(po.name) + + self.assertEqual([item.item_code for item in receipt.items], [self.first_item]) + + def test_receiving_a_closed_row_is_blocked(self): + po = self.make_purchase_order() + receipt = make_purchase_receipt(po.name) + + self.close_items(po, [po.items[1]]) + + receipt.insert() + self.assertRaises(frappe.ValidationError, receipt.submit) + + def test_reopening_a_row_restores_pending_qty(self): + po = self.make_purchase_order() + self.close_items(po, [po.items[1]]) + self.assertEqual(get_ordered_qty(self.second_item), 0) + + self.close_items(po, [po.items[1]], closed=0) + + self.assertEqual(get_ordered_qty(self.second_item), 10) + self.assertEqual(po.per_received, 0) + self.assertEqual(po.status, "To Receive and Bill") + + def test_closing_is_rejected_for_unsupported_doctype(self): + self.assertRaises( + frappe.ValidationError, + update_closed_status, + "Material Request", + "any-name", + ["any-row"], + 1, + ) + + def test_amending_clears_closed_rows(self): + """Frappe keeps no_copy fields when amending, so the flag must be cleared.""" + po = self.make_purchase_order() + self.close_items(po, [po.items[1]]) + po.cancel() + + amended = frappe.copy_doc(po, ignore_no_copy=True) + amended.docstatus = 0 + amended.amended_from = po.name + amended.insert() + + self.assertFalse(any(row.closed for row in amended.items)) 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..fae96606dc9 --- /dev/null +++ b/erpnext/controllers/tests/test_item_close_billing.py @@ -0,0 +1,264 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt + +from erpnext.controllers.item_close import update_closed_status +from erpnext.controllers.sales_and_purchase_return import make_return_doc +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_a_row_does_not_inflate_billing_percentage(self): + receipt = self.make_purchase_receipt() + self.assertEqual(receipt.per_billed, 0) + + self.close_items(receipt, [receipt.items[1]]) + + # nothing was billed, so the receipt must not read as partly billed + self.assertEqual(receipt.per_billed, 0) + self.assertEqual(receipt.status, "To Bill") + + def test_closing_every_row_closes_the_receipt(self): + receipt = self.make_purchase_receipt() + + self.close_items(receipt, receipt.items) + + # nothing was billed, and writing every row off must not claim otherwise + self.assertEqual(receipt.per_billed, 0) + 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, 0) + + def test_unbilled_return_row_can_be_closed(self): + """Return rows are closable by design, not by an accident of sign.""" + receipt = self.make_purchase_receipt() + return_receipt = make_return_doc("Purchase Receipt", receipt.name) + return_receipt.insert() + return_receipt.submit() + + row = return_receipt.items[0] + self.assertLess(row.amount, 0) + self.assertTrue(return_receipt.is_item_closable(row)) + + self.close_items(return_receipt, [row]) + self.assertTrue(return_receipt.items[0].closed) + + +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_a_row_does_not_inflate_billing_percentage(self): + note = self.make_delivery_note() + self.assertEqual(note.per_billed, 0) + + self.close_items(note, [note.items[1]]) + + # nothing was billed, so the note must not read as partially billed + self.assertEqual(note.per_billed, 0) + self.assertEqual(note.status, "To Bill") + + def test_closing_every_row_closes_the_note(self): + note = self.make_delivery_note() + + self.close_items(note, note.items) + + # nothing was billed, and writing every row off must not claim otherwise + self.assertEqual(note.per_billed, 0) + 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") + + def test_amending_clears_closed_rows(self): + """Frappe keeps no_copy fields when amending, so the flag must be cleared.""" + note = self.make_delivery_note() + self.close_items(note, [note.items[1]]) + note.cancel() + + amended = frappe.copy_doc(note, ignore_no_copy=True) + amended.docstatus = 0 + amended.amended_from = note.name + amended.insert() + + self.assertFalse(any(row.closed for row in amended.items)) + + def test_noncanonical_closed_value_is_normalised(self): + """A truthy non-1 value must not slip past the exact-match submission guard.""" + note = self.make_delivery_note() + + update_closed_status("Delivery Note", note.name, [note.items[1].name], 2) + + note.reload() + self.assertEqual(note.items[1].closed, 1) + + def test_unbilled_return_row_can_be_closed(self): + """Return rows carry negative amounts and must still be closable.""" + note = self.make_delivery_note() + return_note = make_return_doc("Delivery Note", note.name) + return_note.insert() + return_note.submit() + + row = return_note.items[0] + self.assertLess(row.amount, 0) + self.assertTrue(return_note.is_item_closable(row)) + + self.close_items(return_note, [row]) + self.assertTrue(return_note.items[0].closed) + + def test_return_row_pending_amount_is_a_magnitude(self): + """The dialog shows what is outstanding, so a return row must not read as zero.""" + note = self.make_delivery_note() + return_note = make_return_doc("Delivery Note", note.name) + return_note.insert() + return_note.submit() + + row = return_note.items[0] + self.assertLess(row.amount, 0) + pending = abs(flt(row.amount)) - abs(flt(row.billed_amt)) + self.assertEqual(pending, abs(flt(note.items[0].amount))) + self.assertGreater(pending, 0) + + def test_closing_a_return_row_leaves_the_original_untouched(self): + """Writing off a credit note must not disturb what was returned.""" + note = self.make_delivery_note() + return_note = make_return_doc("Delivery Note", note.name) + return_note.insert() + return_note.submit() + + note.reload() + before = [(row.returned_qty, row.closed) for row in note.items] + per_returned_before = note.per_returned + + self.close_items(return_note, [return_note.items[0]]) + + note.reload() + self.assertEqual([(row.returned_qty, row.closed) for row in note.items], before) + self.assertEqual(note.per_returned, per_returned_before) + + def test_closing_the_unbilled_row_completes_the_note(self): + """The point of the feature: a written off row stops holding billing open.""" + note = self.make_delivery_note() + invoice = make_sales_invoice(note.name) + invoice.items = [item for item in invoice.items if item.item_code == self.first_item] + invoice.insert() + invoice.submit() + + note.reload() + self.assertEqual(note.per_billed, 50) + + self.close_items(note, [note.items[1]]) + + self.assertEqual(note.per_billed, 100) + self.assertEqual(note.status, "Completed") 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..ad45d6ead26 --- /dev/null +++ b/erpnext/controllers/tests/test_item_close_sales_order.py @@ -0,0 +1,141 @@ +# 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 ff6e0e5e0d4..3554e938e9e 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -25,6 +25,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..0742f75485c --- /dev/null +++ b/erpnext/public/js/utils/item_close.js @@ -0,0 +1,141 @@ +frappe.provide("erpnext"); + +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(); + }, + + fulfilment_config({ qty_field, qty_label, help }) { + return { + is_closable: (item) => + !item.closed && + (flt(item[qty_field]) < flt(item.qty) || + Math.abs(flt(item.billed_amt)) < Math.abs(flt(item.amount))), + help: help, + summarise: (item) => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + fulfilled_qty: item[qty_field] || 0, + pending_qty: Math.max(flt(item.qty) - flt(item[qty_field]), 0), + pending_amount: Math.max(Math.abs(flt(item.amount)) - Math.abs(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("fulfilled_qty", qty_label), + erpnext.item_close.column("pending_qty", __("Pending Qty")), + erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2), + ], + }; + }, + + billing_config(invoice_label) { + return { + is_closable: (item) => + !item.closed && Math.abs(flt(item.billed_amt)) < Math.abs(flt(item.amount)), + help: __( + "Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a {0}.", + [invoice_label] + ), + 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(Math.abs(flt(item.amount)) - Math.abs(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), + ], + }; + }, + + 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 fafeb810660..ad6cf6c8695 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -13,6 +13,7 @@ from frappe.query_builder.functions import Sum from frappe.utils import add_days, cint, flt, nowdate, strip_html from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.controllers.item_close import is_bundle_of_closed_row from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, get_sales_orders, @@ -130,7 +131,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 +144,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, }, @@ -337,7 +340,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, } @@ -603,6 +606,7 @@ def make_sales_invoice( "postprocess": update_item, "condition": lambda doc: not args.get("skip_item_mapping") and select_item(doc) + and not doc.closed and ( True if is_unit_price_row(doc) @@ -818,7 +822,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", @@ -840,7 +844,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, @@ -1042,6 +1047,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) ) @@ -1144,7 +1150,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 69d0b66040b..b5d95c5ce39 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"; @@ -1008,13 +1010,15 @@ 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") { @@ -1023,6 +1027,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex (item) => !item.skip_delivery && item.delivered_by_supplier === 0 && + !item.closed && item.qty > flt(item.delivered_qty) ); allow_delivery = @@ -1047,7 +1052,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"), () => { @@ -1259,6 +1268,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } this.order_type(doc); + + this.set_item_close_buttons(); } items_add(doc, cdt, cdn) { @@ -1872,6 +1883,19 @@ 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, + erpnext.item_close.fulfilment_config({ + qty_field: "delivered_qty", + qty_label: __("Delivered Qty"), + 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." + ), + }) + ); + } 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 3996b9c716a..7eb5c1898e3 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -585,6 +585,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 super().is_item_closable(item) + + 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 930481d2498..ef9d4dd01f5 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") @@ -74,7 +121,7 @@ class StatusService: total_qty = 0.0 per_picked = 0.0 - for so_item in doc.items: + for so_item in [item for item in doc.items if not item.closed] or doc.items: if cint( frappe.get_cached_value("Item", so_item.item_code, "is_stock_item") ) or doc.has_product_bundle(so_item.item_code): 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 4415e3e0843..d394354abcd 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", @@ -628,6 +629,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", @@ -1067,7 +1077,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-08-27 10:55:37.000000", + "modified": "2026-08-27 11:55:37.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 d0f77fc0a2d..40eaf697362 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/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 6c5e1fadf04..58608d804dd 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,10 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( this.update_status("Submitted"); } + set_item_close_buttons() { + erpnext.item_close.add_buttons(this.frm, erpnext.item_close.billing_config(__("Sales Invoice"))); + } + 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 96b14d598a0..14ef9971502 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -626,6 +626,9 @@ 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 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..4ee9ccc13d4 100644 --- a/erpnext/stock/doctype/delivery_note/mapper.py +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -15,6 +15,7 @@ from frappe.utils import flt from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes +from erpnext.controllers.item_close import is_bundle_of_closed_row from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle @@ -123,7 +124,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 +255,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 +294,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 +310,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 +581,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 2f7ee918c8f..831587d78df 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 671cd33d298..1125d2cc226 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", @@ -704,6 +705,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", @@ -983,7 +993,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-07 18:00: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..a1a358ad5ac 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,20 @@ 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, erpnext.item_close.billing_config(__("Purchase Invoice"))); } make_purchase_invoice() { diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 6cbda056327..c74261b1100 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, @@ -498,10 +499,16 @@ 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 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 3f78deb5ff5..55e4914384b 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py +++ b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py @@ -196,7 +196,7 @@ def update_billing_percentage( billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) - for item in pr_doc.items: + for item in [item for item in pr_doc.items if not item.closed] or pr_doc.items: returned_qty = flt(item_wise_returned_qty.get(item.name)) returned_amount = flt(returned_qty) * flt(item.rate) pending_amount = flt(item.amount) - returned_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 6b9e105fa34..996632d17a7 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", @@ -646,6 +647,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", @@ -1145,7 +1155,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-07 18:00: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 diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 62f031e828e..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 ) @@ -219,6 +222,7 @@ def get_purchase_order_qty(item_code, warehouse): & (PurchaseOrder.status.notin(["Closed", "Delivered"])) & (PurchaseOrder.docstatus == 1) & (Coalesce(PurchaseOrderItem.delivered_by_supplier, 0) == 0) + & (Coalesce(PurchaseOrderItem.closed, 0) == 0) ) .run() )