feat: close individual Delivery Note and Purchase Receipt items

Extends row level close to the two documents where the goods have already
moved, so closing a row writes off what is left to bill rather than what is
left to fulfil. Nothing is released in Bin.

Delivery Note and Purchase Receipt are billed through their own services
rather than through status_updater, so their invoices declare the row link in
`closed_source_links`. Without it a closed row stayed invoiceable, since the
existing guard only walked status_updater args.

`per_returned` shares the percentage funnel on both doctypes and is excluded
from `SETTLED_BY_CLOSE`: closing a row writes off pending billing, it does not
turn the row into a return.

Closed rows now show a grey indicator in the items grid on all four doctypes.
Purchase Receipt had no indicator formatter at all and gets one.
This commit is contained in:
Mihir Kandoi
2026-07-29 15:18:20 +05:30
parent 9e1a1fa59c
commit 354708a54e
20 changed files with 347 additions and 34 deletions

View File

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

View File

@@ -273,6 +273,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"""

View File

@@ -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";
@@ -283,7 +285,6 @@ 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");
@@ -438,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() {

View File

@@ -15,7 +15,12 @@ import frappe
from frappe import _
from frappe.utils import cint
REOPEN_STATUS = {"Purchase Order": "Submitted", "Sales Order": "Draft"}
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")
@@ -38,9 +43,7 @@ def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field:
@frappe.whitelist()
def update_closed_status(
doctype: str, name: str, item_names: str | list[str], closed: int
) -> None:
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)))

View File

@@ -198,23 +198,36 @@ class StatusUpdater(Document):
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 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
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(args["source_dt"]):
if d.get(args["join_field"]):
row_idx[d.get(args["join_field"])] = d.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
@@ -230,7 +243,7 @@ class StatusUpdater(Document):
_("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),
_(target_parent_dt),
frappe.bold(row.parent),
)
)

View File

@@ -0,0 +1,160 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.controllers.item_close import update_closed_status
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_row_settles_billing_percentage(self):
receipt = self.make_purchase_receipt()
self.assertEqual(receipt.per_billed, 0)
self.close_items(receipt, [receipt.items[1]])
self.assertEqual(receipt.per_billed, 50)
def test_closing_every_row_closes_the_receipt(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, receipt.items)
self.assertEqual(receipt.per_billed, 100)
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, 50)
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_row_settles_billing_percentage(self):
note = self.make_delivery_note()
self.assertEqual(note.per_billed, 0)
self.close_items(note, [note.items[1]])
self.assertEqual(note.per_billed, 50)
def test_closing_every_row_closes_the_note(self):
note = self.make_delivery_note()
self.close_items(note, note.items)
self.assertEqual(note.per_billed, 100)
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")

View File

@@ -1,3 +1,5 @@
frappe.provide("erpnext");
erpnext.item_close = {
add_buttons(frm, config) {
if (frm.doc.docstatus != 1 || !frm.has_perm("submit")) {
@@ -42,16 +44,14 @@ erpnext.item_close = {
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),
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);
const selected = dialog.fields_dict.items.grid.get_selected_children().map((row) => row.name);
if (!selected.length) {
frappe.msgprint(__("Select at least one row"));

View File

@@ -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";
@@ -973,7 +975,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
var me = this;
super.refresh();
let allow_delivery = false;
this.set_item_close_buttons();
if (doc.docstatus == 1) {
if (
@@ -1266,6 +1267,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
this.order_type(doc);
this.set_item_close_buttons();
}
items_add(doc, cdt, cdn) {

View File

@@ -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,30 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
this.update_status("Submitted");
}
set_item_close_buttons() {
erpnext.item_close.add_buttons(this.frm, {
is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount),
help: __(
"Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Sales Invoice."
),
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(flt(item.amount) - 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),
],
});
}
update_status(status) {
var me = this;
frappe.ui.form.is_saving = true;

View File

@@ -642,6 +642,12 @@ 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 is_item_closable(self, item):
return flt(item.billed_amt) < flt(item.amount)
def update_billing_status(self, update_modified=True):
BillingStatusService(self).update_billing_status(update_modified)

View File

@@ -58,6 +58,14 @@ def get_returned_qty_map(delivery_note: str) -> dict:
return returned_qty_map
def is_bundle_of_closed_row(packed_item) -> bool:
"""A packed item follows the Delivery Note Item row that bundles it."""
return bool(
packed_item.parent_detail_docname
and frappe.db.get_value("Delivery Note Item", packed_item.parent_detail_docname, "closed")
)
@frappe.whitelist()
def make_sales_invoice(
source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None
@@ -123,7 +131,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 +262,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 +301,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 +317,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 +588,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,
},
},

View File

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

View File

@@ -62,6 +62,7 @@
"base_net_rate",
"base_net_amount",
"billed_amt",
"closed",
"incoming_rate",
"item_weight_details",
"weight_per_unit",
@@ -703,6 +704,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",
@@ -982,7 +992,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-07-29 12:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

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

View File

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

View File

@@ -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,40 @@ 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, {
is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount),
help: __(
"Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Purchase Invoice."
),
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(flt(item.amount) - 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),
],
});
}
make_purchase_invoice() {

View File

@@ -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,
@@ -519,10 +520,19 @@ 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 is_item_closable(self, item):
return flt(item.billed_amt) < flt(item.amount)
def update_billing_status(self, update_modified=True):
BillingStatusService(self).update_billing_status(update_modified)

View File

@@ -198,7 +198,7 @@ def update_billing_percentage(
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
total_amount += total_billable_amount
total_billed_amount += abs(flt(item.billed_amt))
total_billed_amount += total_billable_amount if item.closed else abs(flt(item.billed_amt))
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
total_amount = total_billed_amount

View File

@@ -73,6 +73,7 @@
"landed_cost_voucher_amount",
"amount_difference_with_purchase_invoice",
"billed_amt",
"closed",
"warehouse_and_reference",
"warehouse",
"rejected_warehouse",
@@ -645,6 +646,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",
@@ -1144,7 +1154,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-16 15:00:00.000000",
"modified": "2026-07-29 12:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",

View File

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