Files
erpnext/erpnext/controllers/item_close.py
Mihir Kandoi eb7136d8ca 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.
2026-07-29 14:39:29 +05:30

111 lines
3.2 KiB
Python

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