feat: close individual transaction items (#57596)

This commit is contained in:
Mihir Kandoi
2026-08-31 16:57:48 +05:30
committed by GitHub
parent 0f14f8050f
commit d5789c2e8b
37 changed files with 1303 additions and 45 deletions

View File

@@ -729,6 +729,7 @@ def get_ordered_amount(params):
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (child.closed == 0)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)

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

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

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

@@ -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";
@@ -340,7 +342,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 +354,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 +367,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"),
() => {
@@ -433,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() {
@@ -697,6 +705,19 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
this.frm.cscript.update_status("Close", "Closed");
}
set_item_close_buttons() {
erpnext.item_close.add_buttons(
this.frm,
erpnext.item_close.fulfilment_config({
qty_field: "received_qty",
qty_label: __("Received Qty"),
help: __(
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
),
})
);
}
update_dropship_delivered_qty() {
const data = this.frm.doc.items
.filter((item) => item.delivered_by_supplier == 1)

View File

@@ -402,6 +402,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 super().is_item_closable(item)
def on_submit(self):
super().on_submit()
@@ -531,7 +537,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")
@@ -39,10 +55,9 @@ class StatusService:
def update_receiving_percentage(self) -> None:
doc = self.doc
total_qty, received_qty = 0.0, 0.0
for item in doc.items:
for item in [item for item in doc.items if not item.closed] or doc.items:
received_qty += 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)
else:
doc.db_set("per_received", 0, update_modified=False)
per_received = flt(received_qty / total_qty) * 100 if total_qty else 0
doc.db_set("per_received", per_received, update_modified=False)

View File

@@ -86,6 +86,7 @@
"returned_qty",
"column_break_60",
"billed_amt",
"closed",
"accounting_details",
"expense_account",
"column_break_fyqr",
@@ -646,6 +647,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",
@@ -945,7 +955,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2026-08-27 11:55:37.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

@@ -39,6 +39,7 @@ from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.controllers.item_close import clear_closed_rows_on_amend
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -227,7 +228,23 @@ class AccountsController(TransactionBase):
return False
def is_item_closable(self, item):
"""A row can be closed while anything is still pending on it.
Billing is the axis every closable document shares; the order doctypes
extend this with their own fulfilment axis.
Amounts are compared as magnitudes so that return rows stay closable.
That is deliberate: writing off a credit note that will never be issued
is a real decision, and closing a whole return document is already
allowed. Leaving it to the sign of the amount would decide it by
accident.
"""
return abs(flt(item.billed_amt)) < abs(flt(item.amount))
def validate(self):
clear_closed_rows_on_amend(self)
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()

View File

@@ -0,0 +1,145 @@
# 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.
`REOPEN_STATUS` holds, per closable parent, the status its own Re-open button
passes to `update_status`. `set_status` recomputes from `status_map` anyway, so
the value is mostly a sentinel for "clear the Closed override" -- but not
always: Sales Order re-checks the credit limit only on the literal "Draft".
Reusing each doctype's own value keeps reopening a row indistinguishable from
reopening the document by hand.
"""
import frappe
from frappe import _
from frappe.utils import cint
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")
def has_closable_items(doctype: str | None) -> bool:
return doctype in REOPEN_STATUS
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 = 1 if cint(closed) else 0
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 completed in full, so there is nothing to close").format(
settled[0].idx, frappe.bold(settled[0].item_code)
)
)
validate_rows = getattr(doc, "validate_item_close", None)
if validate_rows:
validate_rows(changed)
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(REOPEN_STATUS[doc.doctype])
def is_bundle_of_closed_row(packed_item) -> bool:
"""A packed item follows the row of its parent document that bundles it."""
if not packed_item.parent_detail_docname or not packed_item.parenttype:
return False
item_doctype = f"{packed_item.parenttype} Item"
return bool(frappe.db.get_value(item_doctype, packed_item.parent_detail_docname, "closed"))
def clear_closed_rows_on_amend(doc) -> None:
"""An amended document starts with nothing written off.
Frappe copies `no_copy` fields when amending so a cancelled document can be
corrected and resubmitted, which would otherwise carry a write-off decision
that was made against the cancelled document onto the new one.
"""
if not doc.is_new() or not doc.get("amended_from") or not has_closable_items(doc.doctype):
return
for row in doc.get("items") or []:
row.closed = 0
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,60 @@ 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 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 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(source_dt):
if d.get(join_field):
row_idx[d.get(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),
_(target_parent_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"):
@@ -605,16 +658,28 @@ class StatusUpdater(Document):
@staticmethod
def _calculate_target_parent_percentage(
name, target_parent_dt, target_dt, target_ref_field, target_field, exclude_field=None
name,
target_parent_dt,
target_dt,
target_ref_field,
target_field,
target_parent_field=None,
exclude_field=None,
):
filters = {"parent": name, "parenttype": target_parent_dt}
if exclude_field:
filters[exclude_field] = 0
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=filters,
fields=[target_ref_field, target_field],
fields=fields,
)
if exclude_field and not child_records:
@@ -623,13 +688,19 @@ class StatusUpdater(Document):
# 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
sum_ref = sum(abs(record[ref_key]) for record in child_records)
# A closed row is written off, so it leaves the denominator rather than
# counting as done. The percentage stays a true measure of what was
# actually received, delivered or billed against what is still expected.
# Once every row is written off there is nothing left to measure against,
# so fall back to the whole table and report what actually happened.
open_records = [r for r in child_records if not (tracks_closed_rows and r["closed"])]
basis = open_records or child_records
sum_ref = sum(abs(record[ref_key]) for record in basis)
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(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100,
6,
)
else:
@@ -678,6 +749,7 @@ class StatusUpdater(Document):
args["target_dt"],
args["target_ref_field"],
args["target_field"],
args["target_parent_field"],
args.get("exclude_field"),
)
# update field

View File

@@ -337,8 +337,12 @@ class StockController(AccountsController):
items = frappe.get_all(
"Delivery Note Item",
filters={"parent": self.name, "parenttype": "Delivery Note"},
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"],
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt", "closed"],
)
# A written off row leaves the basis. Once every row is written off there is
# nothing left to measure against, so fall back to the whole table.
items = [item for item in items if not item.closed] or items
total_amount = sum(flt(item.amount) for item in items)
total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items)
# Preserve the original amount basis once the entire Delivery Note is returned.

View File

@@ -0,0 +1,231 @@
# 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)
# nothing received, and the closed row is written off rather than counted
self.assertEqual(po.per_received, 0)
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)
# billing written off, but the goods really did arrive
self.assertEqual(po.per_billed, 0)
self.assertEqual(po.per_received, 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,
)
def test_amending_clears_closed_rows(self):
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
po = self.make_purchase_order()
self.close_items(po, [po.items[1]])
po.cancel()
amended = frappe.copy_doc(po, ignore_no_copy=True)
amended.docstatus = 0
amended.amended_from = po.name
amended.insert()
self.assertFalse(any(row.closed for row in amended.items))

View File

@@ -0,0 +1,264 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
from erpnext.controllers.item_close import update_closed_status
from erpnext.controllers.sales_and_purchase_return import make_return_doc
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_a_row_does_not_inflate_billing_percentage(self):
receipt = self.make_purchase_receipt()
self.assertEqual(receipt.per_billed, 0)
self.close_items(receipt, [receipt.items[1]])
# nothing was billed, so the receipt must not read as partly billed
self.assertEqual(receipt.per_billed, 0)
self.assertEqual(receipt.status, "To Bill")
def test_closing_every_row_closes_the_receipt(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, receipt.items)
# nothing was billed, and writing every row off must not claim otherwise
self.assertEqual(receipt.per_billed, 0)
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, 0)
def test_unbilled_return_row_can_be_closed(self):
"""Return rows are closable by design, not by an accident of sign."""
receipt = self.make_purchase_receipt()
return_receipt = make_return_doc("Purchase Receipt", receipt.name)
return_receipt.insert()
return_receipt.submit()
row = return_receipt.items[0]
self.assertLess(row.amount, 0)
self.assertTrue(return_receipt.is_item_closable(row))
self.close_items(return_receipt, [row])
self.assertTrue(return_receipt.items[0].closed)
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_a_row_does_not_inflate_billing_percentage(self):
note = self.make_delivery_note()
self.assertEqual(note.per_billed, 0)
self.close_items(note, [note.items[1]])
# nothing was billed, so the note must not read as partially billed
self.assertEqual(note.per_billed, 0)
self.assertEqual(note.status, "To Bill")
def test_closing_every_row_closes_the_note(self):
note = self.make_delivery_note()
self.close_items(note, note.items)
# nothing was billed, and writing every row off must not claim otherwise
self.assertEqual(note.per_billed, 0)
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")
def test_amending_clears_closed_rows(self):
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
note = self.make_delivery_note()
self.close_items(note, [note.items[1]])
note.cancel()
amended = frappe.copy_doc(note, ignore_no_copy=True)
amended.docstatus = 0
amended.amended_from = note.name
amended.insert()
self.assertFalse(any(row.closed for row in amended.items))
def test_noncanonical_closed_value_is_normalised(self):
"""A truthy non-1 value must not slip past the exact-match submission guard."""
note = self.make_delivery_note()
update_closed_status("Delivery Note", note.name, [note.items[1].name], 2)
note.reload()
self.assertEqual(note.items[1].closed, 1)
def test_unbilled_return_row_can_be_closed(self):
"""Return rows carry negative amounts and must still be closable."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
row = return_note.items[0]
self.assertLess(row.amount, 0)
self.assertTrue(return_note.is_item_closable(row))
self.close_items(return_note, [row])
self.assertTrue(return_note.items[0].closed)
def test_return_row_pending_amount_is_a_magnitude(self):
"""The dialog shows what is outstanding, so a return row must not read as zero."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
row = return_note.items[0]
self.assertLess(row.amount, 0)
pending = abs(flt(row.amount)) - abs(flt(row.billed_amt))
self.assertEqual(pending, abs(flt(note.items[0].amount)))
self.assertGreater(pending, 0)
def test_closing_a_return_row_leaves_the_original_untouched(self):
"""Writing off a credit note must not disturb what was returned."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
note.reload()
before = [(row.returned_qty, row.closed) for row in note.items]
per_returned_before = note.per_returned
self.close_items(return_note, [return_note.items[0]])
note.reload()
self.assertEqual([(row.returned_qty, row.closed) for row in note.items], before)
self.assertEqual(note.per_returned, per_returned_before)
def test_closing_the_unbilled_row_completes_the_note(self):
"""The point of the feature: a written off row stops holding billing open."""
note = self.make_delivery_note()
invoice = make_sales_invoice(note.name)
invoice.items = [item for item in invoice.items if item.item_code == self.first_item]
invoice.insert()
invoice.submit()
note.reload()
self.assertEqual(note.per_billed, 50)
self.close_items(note, [note.items[1]])
self.assertEqual(note.per_billed, 100)
self.assertEqual(note.status, "Completed")

View File

@@ -0,0 +1,141 @@
# 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.controllers.item_close import update_closed_status
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
def get_reserved_qty(item_code):
return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "reserved_qty"))
class TestSalesOrderItemClose(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_sales_order(self):
so = make_sales_order(
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
)
so.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
"delivery_date": add_days(nowdate(), 1),
},
)
so.save()
so.submit()
return so
def close_items(self, so, rows, closed=1):
update_closed_status("Sales Order", so.name, [row.name for row in rows], closed)
so.reload()
def test_closing_row_releases_reserved_qty(self):
so = self.make_sales_order()
self.assertEqual(get_reserved_qty(self.second_item), 10)
self.close_items(so, [so.items[1]])
self.assertEqual(get_reserved_qty(self.second_item), 0)
self.assertEqual(get_reserved_qty(self.first_item), 10)
def test_closing_row_settles_delivery_percentage(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
note.items = [item for item in note.items if item.item_code == self.first_item]
note.insert()
note.submit()
so.reload()
self.assertEqual(so.per_delivered, 50)
self.close_items(so, [so.items[1]])
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.delivery_status, "Fully Delivered")
def test_closing_every_row_closes_the_order(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.assertEqual(so.status, "Closed")
self.assertEqual(get_reserved_qty(self.first_item), 0)
self.assertEqual(get_reserved_qty(self.second_item), 0)
def test_reopening_one_row_reopens_the_parent(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.close_items(so, [so.items[1]], closed=0)
self.assertNotEqual(so.status, "Closed")
self.assertTrue(so.items[0].closed)
self.assertFalse(so.items[1].closed)
self.assertEqual(get_reserved_qty(self.second_item), 10)
self.assertEqual(get_reserved_qty(self.first_item), 0)
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.assertRaises(frappe.ValidationError, so.update_status, "Draft")
so.reload()
self.assertEqual(so.status, "Closed")
def test_closed_row_is_not_mapped_to_delivery_note(self):
so = self.make_sales_order()
self.close_items(so, [so.items[1]])
note = make_delivery_note(so.name)
self.assertEqual([item.item_code for item in note.items], [self.first_item])
def test_closed_row_is_not_mapped_to_sales_invoice(self):
so = self.make_sales_order()
self.close_items(so, [so.items[1]])
invoice = make_sales_invoice(so.name)
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
def test_delivering_a_closed_row_is_blocked(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
self.close_items(so, [so.items[1]])
note.insert()
self.assertRaises(frappe.ValidationError, note.submit)
def test_settled_row_cannot_be_closed(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
note.insert()
note.submit()
invoice = make_sales_invoice(so.name)
invoice.insert()
invoice.submit()
so.reload()
self.assertRaises(frappe.ValidationError, self.close_items, so, [so.items[0]])

View File

@@ -25,6 +25,7 @@ import "./call_popup/call_popup";
import "./utils/dimension_tree_filter";
import "./utils/ledger_preview.js";
import "./utils/unreconcile.js";
import "./utils/item_close";
import "./utils/barcode_scanner";
import "./telephony";
import "./templates/call_link.html";

View File

@@ -0,0 +1,141 @@
frappe.provide("erpnext");
erpnext.item_close = {
add_buttons(frm, config) {
if (frm.doc.docstatus != 1 || !frm.has_perm("submit")) {
return;
}
if (frm.doc.status != "Closed" && frm.doc.items.some((item) => config.is_closable(item))) {
frm.add_custom_button(
__("Close Items"),
() => erpnext.item_close.select_rows(frm, config, 1),
__("Status")
);
}
if (frm.doc.items.some((item) => item.closed)) {
frm.add_custom_button(
__("Reopen Items"),
() => erpnext.item_close.select_rows(frm, config, 0),
__("Status")
);
}
},
select_rows(frm, config, closed) {
const rows = frm.doc.items
.filter((item) => (closed ? config.is_closable(item) : item.closed))
.map((item) => Object.assign({ name: item.name }, config.summarise(item)));
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">${config.help}</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 }].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);
if (!selected.length) {
frappe.msgprint(__("Select at least one row"));
return;
}
dialog.hide();
frappe.call({
method: "erpnext.controllers.item_close.update_closed_status",
args: {
doctype: frm.doc.doctype,
name: frm.doc.name,
item_names: selected,
closed: closed,
},
freeze: true,
callback: () => frm.reload_doc(),
});
},
});
dialog.show();
},
fulfilment_config({ qty_field, qty_label, help }) {
return {
is_closable: (item) =>
!item.closed &&
(flt(item[qty_field]) < flt(item.qty) ||
Math.abs(flt(item.billed_amt)) < Math.abs(flt(item.amount))),
help: help,
summarise: (item) => ({
item_code: item.item_code,
item_name: item.item_name,
qty: item.qty,
fulfilled_qty: item[qty_field] || 0,
pending_qty: Math.max(flt(item.qty) - flt(item[qty_field]), 0),
pending_amount: Math.max(Math.abs(flt(item.amount)) - Math.abs(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("fulfilled_qty", qty_label),
erpnext.item_close.column("pending_qty", __("Pending Qty")),
erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2),
],
};
},
billing_config(invoice_label) {
return {
is_closable: (item) =>
!item.closed && Math.abs(flt(item.billed_amt)) < Math.abs(flt(item.amount)),
help: __(
"Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a {0}.",
[invoice_label]
),
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(Math.abs(flt(item.amount)) - Math.abs(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),
],
};
},
column(fieldname, label, fieldtype = "Float", columns = 1) {
return {
fieldname: fieldname,
fieldtype: fieldtype,
label: label,
in_list_view: 1,
read_only: 1,
columns: columns,
};
},
};

View File

@@ -13,6 +13,7 @@ from frappe.query_builder.functions import Sum
from frappe.utils import add_days, cint, flt, nowdate, strip_html
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
from erpnext.controllers.item_close import is_bundle_of_closed_row
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests,
get_sales_orders,
@@ -130,7 +131,8 @@ def make_material_request(source_name: str, target_doc: str | dict | Document |
"Packed Item": {
"doctype": "Material Request Item",
"field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"},
"condition": lambda item: get_remaining_packed_item_qty(item) > 0,
"condition": lambda item: get_remaining_packed_item_qty(item) > 0
and not is_bundle_of_closed_row(item),
"postprocess": update_item,
},
"Sales Order Item": {
@@ -142,6 +144,7 @@ def make_material_request(source_name: str, target_doc: str | dict | Document |
"bom_no": "bom_no",
},
"condition": lambda item: not is_product_bundle(item.item_code)
and not item.closed
and get_remaining_qty(item) > 0,
"postprocess": update_item,
},
@@ -337,7 +340,7 @@ def make_delivery_note(
"name": "so_detail",
"parent": "against_sales_order",
},
"condition": lambda d: condition(d) and select_item(d),
"condition": lambda d: condition(d) and not d.closed and select_item(d),
"postprocess": update_item,
}
@@ -603,6 +606,7 @@ def make_sales_invoice(
"postprocess": update_item,
"condition": lambda doc: not args.get("skip_item_mapping")
and select_item(doc)
and not doc.closed
and (
True
if is_unit_price_row(doc)
@@ -818,7 +822,7 @@ def make_purchase_order(
"margin_rate_or_amount",
],
"postprocess": update_item,
"condition": lambda doc, s=supplier: filter_items(doc, s),
"condition": lambda doc, s=supplier: not doc.closed and filter_items(doc, s),
},
"Packed Item": {
"doctype": "Purchase Order Item",
@@ -840,7 +844,8 @@ def make_purchase_order(
],
"postprocess": update_item_for_packed_item,
"condition": lambda doc: doc.parent_item in item_codes
and flt(doc.ordered_qty) < flt(doc.qty),
and flt(doc.ordered_qty) < flt(doc.qty)
and not is_bundle_of_closed_row(doc),
},
},
target_doc,
@@ -1042,6 +1047,7 @@ def create_pick_list(source_name: str, target_doc: str | dict | Document | None
return (
abs(item.delivered_qty) < abs(item.qty)
and item.delivered_by_supplier != 1
and not item.closed
and not is_product_bundle(item.item_code)
)
@@ -1144,7 +1150,7 @@ def get_mapped_subcontracting_inward_order(
"name": "sales_order_item",
},
"field_no_map": ["qty", "fg_item_qty", "amount"],
"condition": lambda item: item.qty != item.subcontracted_qty,
"condition": lambda item: item.qty != item.subcontracted_qty and not item.closed,
},
},
target_doc,

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";
@@ -1008,13 +1010,15 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
} else if (doc.status === "Closed") {
// un-close
this.frm.add_custom_button(
__("Re-open"),
function () {
me.frm.cscript.update_status("Re-open", "Draft");
},
__("Status")
);
if (!doc.items.every((item) => item.closed)) {
this.frm.add_custom_button(
__("Re-open"),
function () {
me.frm.cscript.update_status("Re-open", "Draft");
},
__("Status")
);
}
}
}
if (doc.status !== "Closed") {
@@ -1023,6 +1027,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
(item) =>
!item.skip_delivery &&
item.delivered_by_supplier === 0 &&
!item.closed &&
item.qty > flt(item.delivered_qty)
);
allow_delivery =
@@ -1047,7 +1052,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
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 Inward Order"),
() => {
@@ -1259,6 +1268,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
this.order_type(doc);
this.set_item_close_buttons();
}
items_add(doc, cdt, cdn) {
@@ -1872,6 +1883,19 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
close_sales_order() {
this.frm.cscript.update_status("Close", "Closed");
}
set_item_close_buttons() {
erpnext.item_close.add_buttons(
this.frm,
erpnext.item_close.fulfilment_config({
qty_field: "delivered_qty",
qty_label: __("Delivered Qty"),
help: __(
"Closed rows stop being expected. Their pending quantity is written off, stock is no longer reserved for them, and they are skipped when creating a Delivery Note or Sales Invoice."
),
})
);
}
update_status(label, status) {
var doc = this.frm.doc;
var me = this;

View File

@@ -585,6 +585,22 @@ class SalesOrder(SellingController):
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.delivered_qty) < flt(item.qty) or super().is_item_closable(item)
def validate_item_close(self, items):
"""Reserved stock has to be released deliberately before a row is closed."""
for item in items:
if has_reserved_stock(self.doctype, self.name, item.name):
frappe.throw(
_("Row #{0}: {1} has reserved stock. Unreserve it before closing the row.").format(
item.idx, frappe.bold(item.item_code)
)
)
def update_reserved_qty(self, so_item_rows=None):
SalesOrderStockReservation(self).update_reserved_qty(so_item_rows)

View File

@@ -8,6 +8,7 @@ from frappe import _
from frappe.desk.notifications import clear_doctype_notifications
from frappe.utils import cint, cstr, flt
from erpnext.controllers.item_close import validate_parent_reopen
from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService
@@ -27,6 +28,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)
# Upon Sales Order Re-open, check for credit limit.
# Limit should be checked after the 'Hold/Closed' status is reset.
@@ -38,6 +43,48 @@ class StatusService:
clear_doctype_notifications(doc)
doc.update_blanket_order()
def recalculate_after_item_close(self) -> None:
"""Refresh progress after row flags changed.
Billing runs last because it reloads the parent and writes the final
status from both percentages.
"""
doc = self.doc
doc.update_reserved_qty()
self.update_picking_status()
self.update_delivery_percentage()
self.update_billing_percentage()
def update_delivery_percentage(self, update_modified: bool = True) -> None:
self.doc._update_percent_field(
{
"target_dt": "Sales Order Item",
"target_parent_dt": "Sales Order",
"target_parent_field": "per_delivered",
"target_ref_field": "qty",
"target_field": "delivered_qty",
"status_field": "delivery_status",
"keyword": "Delivered",
"name": self.doc.name,
},
update_modified,
)
def update_billing_percentage(self, update_modified: bool = True) -> None:
self.doc._update_percent_field(
{
"target_dt": "Sales Order Item",
"target_parent_dt": "Sales Order",
"target_parent_field": "per_billed",
"target_ref_field": "amount",
"target_field": "billed_amt",
"status_field": "billing_status",
"keyword": "Billed",
"name": self.doc.name,
},
update_modified,
)
def check_modified_date(self) -> None:
doc = self.doc
mod_db = frappe.db.get_value("Sales Order", doc.name, "modified")
@@ -74,7 +121,7 @@ class StatusService:
total_qty = 0.0
per_picked = 0.0
for so_item in doc.items:
for so_item in [item for item in doc.items if not item.closed] or doc.items:
if cint(
frappe.get_cached_value("Item", so_item.item_code, "is_stock_item")
) or doc.has_product_bundle(so_item.item_code):

View File

@@ -66,6 +66,7 @@
"base_net_rate",
"base_net_amount",
"billed_amt",
"closed",
"valuation_rate",
"gross_profit",
"drop_ship_section",
@@ -628,6 +629,15 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "closed",
"fieldtype": "Check",
"label": "Closed",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "valuation_rate",
"fieldtype": "Currency",
@@ -1067,7 +1077,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2026-08-27 11:55:37.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -30,6 +30,7 @@ class SalesOrderItem(Document):
blanket_order_rate: DF.Currency
bom_no: 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

@@ -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,10 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
this.update_status("Submitted");
}
set_item_close_buttons() {
erpnext.item_close.add_buttons(this.frm, erpnext.item_close.billing_config(__("Sales Invoice")));
}
update_status(status) {
var me = this;
frappe.ui.form.is_saving = true;

View File

@@ -626,6 +626,9 @@ 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 update_billing_status(self, update_modified=True):
BillingStatusService(self).update_billing_status(update_modified)

View File

@@ -15,6 +15,7 @@ from frappe.utils import flt
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.item_close import is_bundle_of_closed_row
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
@@ -123,7 +124,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 +255,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 +294,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 +310,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 +581,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",
@@ -704,6 +705,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",
@@ -983,7 +993,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-07 18:00: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,20 @@ 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, erpnext.item_close.billing_config(__("Purchase Invoice")));
}
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,
@@ -498,10 +499,16 @@ 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 update_billing_status(self, update_modified=True):
BillingStatusService(self).update_billing_status(update_modified)

View File

@@ -196,7 +196,7 @@ def update_billing_percentage(
billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc)
billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc)
for item in pr_doc.items:
for item in [item for item in pr_doc.items if not item.closed] or pr_doc.items:
returned_qty = flt(item_wise_returned_qty.get(item.name))
returned_amount = flt(returned_qty) * flt(item.rate)
pending_amount = flt(item.amount) - returned_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",
@@ -646,6 +647,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",
@@ -1145,7 +1155,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-07 18:00: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

View File

@@ -96,6 +96,7 @@ def get_reserved_qty(item_code, warehouse):
open_so = (so.docstatus == 1) & so.status.notin(["On Hold", "Closed"])
not_delivered_by_supplier = so_item.delivered_by_supplier.isnull() | (so_item.delivered_by_supplier == 0)
not_closed = so_item.closed.isnull() | (so_item.closed == 0)
# Keep the reserved-qty rollup in the DB (one aggregate per branch) instead of streaming
# every open packed-item / SO-item row into Python. `qty <> 0` mirrors the original
@@ -122,6 +123,7 @@ def get_reserved_qty(item_code, warehouse):
& (packed_item.parenttype == "Sales Order")
& (packed_item.item_code != packed_item.parent_item)
& not_delivered_by_supplier
& not_closed
& open_so
& reservable
)
@@ -138,6 +140,7 @@ def get_reserved_qty(item_code, warehouse):
(so_item.item_code == item_code)
& (so_item.warehouse == warehouse)
& not_delivered_by_supplier
& not_closed
& open_so
& reservable
)
@@ -219,6 +222,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()
)