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:
Mihir Kandoi
2026-07-29 14:53:58 +05:30
parent eb7136d8ca
commit 9e1a1fa59c
12 changed files with 414 additions and 168 deletions

View File

@@ -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(),
});
}

View File

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

View 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]])

View File

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

View File

@@ -0,0 +1,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,
};
},
};

View File

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

View File

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

View File

@@ -541,6 +541,22 @@ class SalesOrder(SellingController):
def update_status(self, status):
StatusService(self).update_status(status)
def on_item_close_status_change(self):
StatusService(self).recalculate_after_item_close()
def is_item_closable(self, item):
return flt(item.delivered_qty) < flt(item.qty) or 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)

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ class SalesOrderItem(Document):
blanket_order_rate: DF.Currency
bom_no: DF.Link | None
brand: DF.Link | None
closed: DF.Check
company_total_stock: DF.Float
conversion_factor: DF.Float
cost_center: DF.Link | None

View File

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