Compare commits

...

11 Commits

Author SHA1 Message Date
Mihir Kandoi
c7e6b0376c test: marginal min order qty overage raises a toast 2026-08-07 22:34:07 +05:30
Mihir Kandoi
3fb0cb92b1 feat: warn when ordered qty exceeds min order qty only by UOM rounding
A minimum order qty defined in stock UOM often has no exact
representation in the purchase UOM, so the smallest valid order slightly
exceeds the minimum. Surface that overage on the Purchase Order with a
toast on first save when an item's ordered stock qty is above its
minimum by less than one purchase-UOM step, so the buyer sees the
marginal increase before sending the order. Sub-precision dust stays
silent.
2026-08-07 22:34:06 +05:30
Mihir Kandoi
0b949f297e Merge remote-tracking branch 'origin/develop' into item-level-close
# Conflicts:
#	erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
#	erpnext/selling/doctype/sales_order_item/sales_order_item.json
#	erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
#	erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
2026-08-07 22:23:04 +05:30
Mihir Kandoi
11d7f02a51 test: make return row closure a stated rule
Whether a return row could be closed was decided by the sign of its amount
rather than by anyone's intent: the signed comparison happened to reject it,
and switching to magnitudes happened to allow it. Closing a whole return
document was already allowed, so allowing the row is the consistent choice,
but it should be written down.

`is_item_closable` now says so, and the behaviour is covered on both Delivery
Note and Purchase Receipt returns rather than only the one that was reported.

Also asserts the property worth protecting: closing a row on a return leaves
the original document's returned qty and per_returned untouched.
2026-07-29 16:35:56 +05:30
Mihir Kandoi
24b5a531bc fix: mirror the magnitude comparison in the close dialog
The server started treating billing amounts as magnitudes so return rows stay
closable, but the client kept the signed comparison. An unbilled return row was
filtered out of the Close Items dialog and hid the button entirely, so the row
the server would accept could not be reached from the form.

The pending amount shown in the dialog had the same flaw and would have
displayed zero outstanding on a row with a full amount left to credit.

Verified on a return Delivery Note: the row now appears with amount -500 and
pending amount 500.
2026-07-29 16:24:44 +05:30
Mihir Kandoi
27dffd916d fix: normalise the closed flag and keep return rows closable
Two issues from review.

The endpoint stored whatever integer the caller passed, so `closed=2` was
treated as closed by every truthy check while `validate_closed_source_items`
looks for exactly 1. A submit-authorized caller could suppress a row from
mapping and still submit an invoice against it. Verified: the value persisted
as 2, truthy checks saw it as closed, the guard query did not find it.

`is_item_closable` compared signed amounts, so an unbilled return row with a
negative amount looked already completed and could not be closed. Verified on a
real return Delivery Note: amount -500, billed_amt 0, predicate false.
Magnitudes are compared instead, matching how the percentage funnel already
treats these fields.
2026-07-29 16:17:50 +05:30
Mihir Kandoi
1e5cc08b1d refactor: share the row close plumbing across doctypes
Removes the duplication left over from adding the doctypes one at a time.

`is_bundle_of_closed_row` was copied between the Sales Order and Delivery Note
mappers, differing only in a doctype name; it now derives that from the packed
item's parenttype. `is_item_closable` was identical on Delivery Note and
Purchase Receipt and repeated the billing clause on the order doctypes; billing
is now the default on AccountsController and the orders add their own
fulfilment axis. The close dialog config was written out per doctype, differing
by a qty field, a column label and a sentence, and is now two builders in
erpnext/public/js/utils/item_close.js.

Also stops counting closed rows as committed spend in the budget's ordered
amount, which sums per row but only guarded the parent status.

When every row is closed there is nothing left to measure against, so the
percentage falls back to the whole table and reports what actually happened.
Writing off two unbilled rows leaves per_billed at 0; writing off two rows that
were fully received leaves per_received at 100 and per_billed at 0. A constant
would have been wrong in one direction or the other.
2026-07-29 15:56:04 +05:30
Mihir Kandoi
992530c706 fix: keep progress percentages honest when a row is closed
A closed row was counted as fully settled, so closing one of two unbilled
Delivery Note rows pushed per_billed to 50 and the document read "Partially
Billed" with nothing invoiced. The same inflation applied to per_received,
per_delivered and per_picked.

Closed rows now leave the denominator instead of counting as done, so a
percentage stays a true measure of what was received, delivered or billed
against what is still expected. Writing off one of two unbilled rows leaves
per_billed at 0; billing the other takes it to 100. When every row is closed
nothing is outstanding, so the percentage is 100 by definition.

This mirrors how Delivery Note already excludes returned value from the
billing denominator.

Amending a cancelled document no longer inherits its write-offs. Frappe copies
no_copy fields when amending so a document can be corrected and resubmitted,
and unlike billed_amt or received_qty nothing recomputes `closed`, so the flag
would silently keep a row out of billing on the new document.
2026-07-29 15:36:22 +05:30
Mihir Kandoi
354708a54e 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.
2026-07-29 15:18:20 +05:30
Mihir Kandoi
9e1a1fa59c feat: close individual Sales Order items
Extends row level close to Sales Order. Closing a row releases its reserved
qty from Bin, settles its delivery and billing progress, and skips it when
creating a Delivery Note, Sales Invoice, Pick List, Material Request or
drop ship Purchase Order.

A row holding Stock Reservation Entries cannot be closed. Releasing physical
stock is a deliberate act, so the row has to be unreserved first rather than
having its reservations cancelled as a side effect.

Packed Items have no flag of their own and follow the Sales Order Item row
that bundles them, both in the mappers and in the reserved qty rollup.

Closed rows count as picked in the picking percentage, so the Pick List
button stops offering a list that would map no rows.

The close dialog moves to erpnext/public/js/utils/item_close.js and is shared
with Purchase Order, with each doctype supplying its own eligibility rule,
help text and columns.

REOPEN_STATUS now records why the per doctype value matters: Sales Order
re-checks the credit limit only on the literal "Draft", so each doctype reuses
whatever its own Re-open button passes.
2026-07-29 14:53:58 +05:30
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
37 changed files with 1335 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

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

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

@@ -319,6 +319,38 @@ class PurchaseOrder(BuyingController):
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
)
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
if not self.is_new():
return
precision = self.items[0].precision("stock_qty")
itemwise_step = frappe._dict()
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
overage = flt(qty) - min_order_qty
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
frappe.toast(
_(
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
).format(
item_code,
flt(qty, precision),
itemwise_stock_uom[item_code],
min_order_qty,
flt(overage, precision),
),
indicator="orange",
)
def get_schedule_dates(self):
for d in self.get("items"):
if d.material_request_item and not d.schedule_date:
@@ -370,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()
@@ -499,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

@@ -724,6 +724,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
below_minimum.items[0].conversion_factor = 0.6
self.assertRaises(frappe.ValidationError, below_minimum.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
def test_uom_integer_check_tolerates_conversion_dust(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError

View File

@@ -86,6 +86,7 @@
"returned_qty",
"column_break_60",
"billed_amt",
"closed",
"accounting_details",
"expense_account",
"column_break_fyqr",
@@ -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
},
{
"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",
@@ -944,7 +954,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": "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,
@@ -210,7 +211,23 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
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,24 +658,36 @@ 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
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:
@@ -668,6 +733,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,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,248 @@
# 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)

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

@@ -26,6 +26,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,
},
@@ -335,7 +338,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,
}
@@ -580,6 +583,7 @@ def make_sales_invoice(
if is_unit_price_row(doc)
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
)
and not doc.closed
and select_item(doc)
and not args.get("skip_item_mapping"),
},
@@ -788,7 +792,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",
@@ -810,7 +814,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,
@@ -1010,6 +1015,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)
)
@@ -1112,7 +1118,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,19 +1010,24 @@ 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") {
if (doc.status !== "On Hold") {
const items_are_deliverable = this.frm.doc.items.some(
(item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
(item) =>
item.delivered_by_supplier === 0 &&
!item.closed &&
item.qty > flt(item.delivered_qty)
);
allow_delivery =
(this.frm.doc.has_unit_price_items || items_are_deliverable) &&
@@ -1044,7 +1051,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"),
() => {
@@ -1256,6 +1267,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
this.order_type(doc);
this.set_item_close_buttons();
}
items_add(doc, cdt, cdn) {
@@ -1871,6 +1884,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

@@ -541,6 +541,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")
@@ -71,7 +118,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",
@@ -618,6 +619,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",
@@ -1056,7 +1066,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": "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

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

@@ -186,7 +186,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()
)