feat: close individual Purchase Order items

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.
This commit is contained in:
Mihir Kandoi
2026-07-29 14:39:29 +05:30
parent e0c31f1745
commit eb7136d8ca
10 changed files with 582 additions and 11 deletions

View File

@@ -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},

View File

@@ -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
? `<p class="text-muted small">${__(
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
)}</p>`
: "",
},
{
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)

View File

@@ -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

View File

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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"))
)
)

View File

@@ -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"):

View File

@@ -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,
)

View File

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