mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 23:18:40 +00:00
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.
This commit is contained in:
@@ -703,153 +703,29 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
}
|
||||
|
||||
set_item_close_buttons() {
|
||||
if (this.frm.doc.docstatus != 1 || !this.frm.has_perm("submit")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.frm.doc.status != "Closed" && this.frm.doc.items.some((item) => this.is_item_closable(item))) {
|
||||
this.frm.add_custom_button(
|
||||
__("Close Items"),
|
||||
() => this.select_items_to_close(1),
|
||||
__("Status")
|
||||
);
|
||||
}
|
||||
|
||||
if (this.frm.doc.items.some((item) => item.closed)) {
|
||||
this.frm.add_custom_button(
|
||||
__("Reopen Items"),
|
||||
() => this.select_items_to_close(0),
|
||||
__("Status")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
is_item_closable(item) {
|
||||
return (
|
||||
!item.closed &&
|
||||
(flt(item.received_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount))
|
||||
);
|
||||
}
|
||||
|
||||
select_items_to_close(closed) {
|
||||
const rows = this.frm.doc.items
|
||||
.filter((item) => (closed ? this.is_item_closable(item) : item.closed))
|
||||
.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
received_qty: item.received_qty || 0,
|
||||
pending_qty: Math.max(flt(item.qty) - flt(item.received_qty), 0),
|
||||
pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0),
|
||||
};
|
||||
});
|
||||
|
||||
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">${__(
|
||||
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
|
||||
)}</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 },
|
||||
{
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
label: __("Item Code"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 3,
|
||||
},
|
||||
{
|
||||
fieldname: "item_name",
|
||||
fieldtype: "Data",
|
||||
label: __("Item Name"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 2,
|
||||
},
|
||||
{
|
||||
fieldname: "qty",
|
||||
fieldtype: "Float",
|
||||
label: __("Qty"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "received_qty",
|
||||
fieldtype: "Float",
|
||||
label: __("Received Qty"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "pending_qty",
|
||||
fieldtype: "Float",
|
||||
label: __("Pending Qty"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "pending_amount",
|
||||
fieldtype: "Currency",
|
||||
label: __("Pending Amount"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
erpnext.item_close.add_buttons(this.frm, {
|
||||
is_closable: (item) =>
|
||||
!item.closed &&
|
||||
(flt(item.received_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount)),
|
||||
help: __(
|
||||
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
|
||||
),
|
||||
summarise: (item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
received_qty: item.received_qty || 0,
|
||||
pending_qty: Math.max(flt(item.qty) - flt(item.received_qty), 0),
|
||||
pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0),
|
||||
}),
|
||||
columns: [
|
||||
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
|
||||
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
|
||||
erpnext.item_close.column("qty", __("Qty")),
|
||||
erpnext.item_close.column("received_qty", __("Received Qty")),
|
||||
erpnext.item_close.column("pending_qty", __("Pending Qty")),
|
||||
erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2),
|
||||
],
|
||||
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();
|
||||
this.update_items_closed_status(selected, closed);
|
||||
},
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
update_items_closed_status(item_names, closed) {
|
||||
frappe.call({
|
||||
method: "erpnext.controllers.item_close.update_closed_status",
|
||||
args: {
|
||||
doctype: this.frm.doc.doctype,
|
||||
name: this.frm.doc.name,
|
||||
item_names: item_names,
|
||||
closed: closed,
|
||||
},
|
||||
freeze: true,
|
||||
callback: () => this.frm.reload_doc(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,25 @@
|
||||
|
||||
"""Row level close and reopen for transaction items.
|
||||
|
||||
Each closable parent maps to the status it is put back into when a row is
|
||||
reopened while the parent itself is closed.
|
||||
`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
|
||||
|
||||
CLOSABLE_PARENTS = {"Purchase Order": "Submitted"}
|
||||
REOPEN_STATUS = {"Purchase Order": "Submitted", "Sales Order": "Draft"}
|
||||
|
||||
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
|
||||
|
||||
|
||||
def has_closable_items(doctype: str | None) -> bool:
|
||||
return doctype in CLOSABLE_PARENTS
|
||||
return doctype in REOPEN_STATUS
|
||||
|
||||
|
||||
def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: str) -> bool:
|
||||
@@ -57,11 +61,15 @@ def update_closed_status(
|
||||
settled = [row for row in changed if not doc.is_item_closable(row)]
|
||||
if settled:
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} is already received and billed in full, so there is nothing to close").format(
|
||||
_("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)
|
||||
|
||||
@@ -92,7 +100,7 @@ def reopen_parent_if_closed(doc) -> None:
|
||||
make reopening a row look like it did nothing.
|
||||
"""
|
||||
if doc.status == "Closed":
|
||||
doc.update_status(CLOSABLE_PARENTS[doc.doctype])
|
||||
doc.update_status(REOPEN_STATUS[doc.doctype])
|
||||
|
||||
|
||||
def validate_parent_reopen(doc) -> None:
|
||||
|
||||
143
erpnext/controllers/tests/test_item_close_sales_order.py
Normal file
143
erpnext/controllers/tests/test_item_close_sales_order.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# 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";
|
||||
|
||||
89
erpnext/public/js/utils/item_close.js
Normal file
89
erpnext/public/js/utils/item_close.js
Normal file
@@ -0,0 +1,89 @@
|
||||
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();
|
||||
},
|
||||
|
||||
column(fieldname, label, fieldtype = "Float", columns = 1) {
|
||||
return {
|
||||
fieldname: fieldname,
|
||||
fieldtype: fieldtype,
|
||||
label: label,
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
columns: columns,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -48,6 +48,14 @@ def get_requested_item_qty(sales_order: str) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def is_bundle_of_closed_row(packed_item) -> bool:
|
||||
"""A packed item follows the Sales Order Item row that bundles it."""
|
||||
return bool(
|
||||
packed_item.parent_detail_docname
|
||||
and frappe.db.get_value("Sales Order Item", packed_item.parent_detail_docname, "closed")
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
requested_item_qty = get_requested_item_qty(source_name)
|
||||
@@ -130,7 +138,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 +151,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 +345,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 +590,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 +799,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 +821,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 +1022,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 +1125,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,
|
||||
|
||||
@@ -973,6 +973,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
var me = this;
|
||||
super.refresh();
|
||||
let allow_delivery = false;
|
||||
this.set_item_close_buttons();
|
||||
|
||||
if (doc.docstatus == 1) {
|
||||
if (
|
||||
@@ -1008,19 +1009,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 +1050,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"),
|
||||
() => {
|
||||
@@ -1871,6 +1881,33 @@ 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, {
|
||||
is_closable: (item) =>
|
||||
!item.closed &&
|
||||
(flt(item.delivered_qty) < flt(item.qty) || flt(item.billed_amt) < flt(item.amount)),
|
||||
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."
|
||||
),
|
||||
summarise: (item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
delivered_qty: item.delivered_qty || 0,
|
||||
pending_qty: Math.max(flt(item.qty) - flt(item.delivered_qty), 0),
|
||||
pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0),
|
||||
}),
|
||||
columns: [
|
||||
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
|
||||
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
|
||||
erpnext.item_close.column("qty", __("Qty")),
|
||||
erpnext.item_close.column("delivered_qty", __("Delivered Qty")),
|
||||
erpnext.item_close.column("pending_qty", __("Pending Qty")),
|
||||
erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2),
|
||||
],
|
||||
});
|
||||
}
|
||||
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 flt(item.billed_amt) < flt(item.amount)
|
||||
|
||||
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")
|
||||
@@ -75,7 +122,9 @@ class StatusService:
|
||||
if cint(
|
||||
frappe.get_cached_value("Item", so_item.item_code, "is_stock_item")
|
||||
) or doc.has_product_bundle(so_item.item_code):
|
||||
total_picked_qty += flt(so_item.picked_qty)
|
||||
total_picked_qty += (
|
||||
flt(so_item.stock_qty) if so_item.closed else flt(so_item.picked_qty)
|
||||
)
|
||||
total_qty += flt(so_item.stock_qty)
|
||||
|
||||
if total_picked_qty and total_qty:
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"base_net_rate",
|
||||
"base_net_amount",
|
||||
"billed_amt",
|
||||
"closed",
|
||||
"valuation_rate",
|
||||
"gross_profit",
|
||||
"drop_ship_section",
|
||||
@@ -617,6 +618,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",
|
||||
@@ -1055,7 +1065,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified": "2026-07-29 12:05: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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user