mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-10 05:01:47 +00:00
Compare commits
11 Commits
party-impo
...
item-level
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e6b0376c | ||
|
|
3fb0cb92b1 | ||
|
|
0b949f297e | ||
|
|
11d7f02a51 | ||
|
|
24b5a531bc | ||
|
|
27dffd916d | ||
|
|
1e5cc08b1d | ||
|
|
992530c706 | ||
|
|
354708a54e | ||
|
|
9e1a1fa59c | ||
|
|
eb7136d8ca |
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
145
erpnext/controllers/item_close.py
Normal file
145
erpnext/controllers/item_close.py
Normal 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"))
|
||||
)
|
||||
)
|
||||
@@ -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"):
|
||||
|
||||
231
erpnext/controllers/tests/test_item_close.py
Normal file
231
erpnext/controllers/tests/test_item_close.py
Normal 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))
|
||||
248
erpnext/controllers/tests/test_item_close_billing.py
Normal file
248
erpnext/controllers/tests/test_item_close_billing.py
Normal 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)
|
||||
141
erpnext/controllers/tests/test_item_close_sales_order.py
Normal file
141
erpnext/controllers/tests/test_item_close_sales_order.py
Normal 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]])
|
||||
@@ -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";
|
||||
|
||||
141
erpnext/public/js/utils/item_close.js
Normal file
141
erpnext/public/js/utils/item_close.js
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user