From eb7136d8cac545c96ccf160f83715e39442b2e88 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 29 Jul 2026 14:39:29 +0530 Subject: [PATCH] feat: close individual Purchase Order items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `closed` flag on Purchase Order Item so a single line can be written off without closing the whole order. Closing a row settles it: its pending quantity stops holding the order open, its ordered qty is released from Bin, and it is skipped when creating a Purchase Receipt or Purchase Invoice. The percentage funnel in StatusUpdater counts a closed row as fully settled, gated on the progress field so `per_returned` is unaffected — closing writes off what is pending, it does not turn a row into a return. Parent and row close stay independent owners: a closed parent does not stamp its rows, and consumers check both. Closing the last open row closes the parent; reopening any row reopens it. Reopening a parent whose rows are all closed is blocked, since it would read as open while every row stayed suppressed. Rows that are already received and billed in full cannot be closed, matching the existing document level gate. Rows received but not yet billed can be, which writes off the remaining billable amount. --- .../buying/doctype/purchase_order/mapper.py | 2 + .../doctype/purchase_order/purchase_order.js | 162 ++++++++++++- .../doctype/purchase_order/purchase_order.py | 8 +- .../doctype/purchase_order/services/status.py | 18 +- .../purchase_order_item.json | 12 +- .../purchase_order_item.py | 1 + erpnext/controllers/item_close.py | 110 +++++++++ erpnext/controllers/status_updater.py | 62 ++++- erpnext/controllers/tests/test_item_close.py | 217 ++++++++++++++++++ erpnext/stock/stock_balance.py | 1 + 10 files changed, 582 insertions(+), 11 deletions(-) create mode 100644 erpnext/controllers/item_close.py create mode 100644 erpnext/controllers/tests/test_item_close.py 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..13676dcec04 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -283,6 +283,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } this.frm.set_df_property("drop_ship", "hidden", !is_drop_ship); + this.set_item_close_buttons(); if (doc.docstatus == 1) { this.frm.fields_dict.items_section.wrapper.addClass("hide-border"); @@ -340,7 +341,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 +353,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 +366,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"), () => { @@ -697,6 +702,157 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( this.frm.cscript.update_status("Close", "Closed"); } + 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, + }, + ], + }, + ], + 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(), + }); + } + 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 0a28177ba74..bbc35fe355f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -369,6 +369,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 flt(item.billed_amt) < flt(item.amount) + def on_submit(self): super().on_submit() @@ -498,7 +504,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..b9c43a5652e 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") @@ -40,7 +56,7 @@ class StatusService: doc = self.doc total_qty, received_qty = 0.0, 0.0 for item in doc.items: - received_qty += min(item.received_qty, item.qty) + received_qty += item.qty if item.closed else 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) 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 b405c0b0be5..e17740535b3 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", @@ -644,6 +645,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", @@ -943,7 +953,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-15 10:30:04.600510", + "modified": "2026-07-29 12:00:00.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/item_close.py b/erpnext/controllers/item_close.py new file mode 100644 index 00000000000..c4a0c16d6cc --- /dev/null +++ b/erpnext/controllers/item_close.py @@ -0,0 +1,110 @@ +# 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. + +Each closable parent maps to the status it is put back into when a row is +reopened while the parent itself is closed. +""" + +import frappe +from frappe import _ +from frappe.utils import cint + +CLOSABLE_PARENTS = {"Purchase Order": "Submitted"} + +SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed") + + +def has_closable_items(doctype: str | None) -> bool: + return doctype in CLOSABLE_PARENTS + + +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 = cint(closed) + 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 received and billed in full, so there is nothing to close").format( + settled[0].idx, frappe.bold(settled[0].item_code) + ) + ) + + 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(CLOSABLE_PARENTS[doc.doctype]) + + +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 eed56008547..dd4260b97c9 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,47 @@ 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 validate_closed_source_items(self): + """Block submitting against rows that were closed on the source document.""" + if self.docstatus != 1: + return + + for args in self.status_updater: + target_dt = args.get("target_dt") + if not target_dt or not has_closable_items(args.get("target_parent_dt")): + continue + + if not frappe.get_meta(target_dt).has_field("closed"): + continue + + row_idx = {} + for d in self.get_all_children(args["source_dt"]): + if d.get(args["join_field"]): + row_idx[d.get(args["join_field"])] = d.idx + + 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), + _(args.get("target_parent_dt") or target_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"): @@ -604,24 +644,35 @@ class StatusUpdater(Document): @staticmethod def _calculate_target_parent_percentage( - name, target_parent_dt, target_dt, target_ref_field, target_field + name, target_parent_dt, target_dt, target_ref_field, target_field, target_parent_field ): + 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={"parent": name, "parenttype": target_parent_dt}, - fields=[target_ref_field, target_field], + fields=fields, ) # 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 + def settled(record): + """A closed row is settled in full, so it stops holding the parent open.""" + if tracks_closed_rows and record["closed"]: + return abs(record[ref_key]) + + return min(abs(record[target_field]), abs(record[ref_key])) + sum_ref = sum(abs(record[ref_key]) for record in child_records) 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(settled(record) for record in child_records) / sum_ref * 100, 6, ) else: @@ -667,6 +718,7 @@ class StatusUpdater(Document): args["target_dt"], args["target_ref_field"], args["target_field"], + args["target_parent_field"], ) # update field if args.get("status_field"): diff --git a/erpnext/controllers/tests/test_item_close.py b/erpnext/controllers/tests/test_item_close.py new file mode 100644 index 00000000000..4daa6f8b7ea --- /dev/null +++ b/erpnext/controllers/tests/test_item_close.py @@ -0,0 +1,217 @@ +# 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) + self.assertEqual(po.per_received, 50) + 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) + + self.assertEqual(po.per_billed, 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, + ) diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 62f031e828e..0e552318faa 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -219,6 +219,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() )