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() )