perf: batch per-row bin lookups in sales invoice and delivery note

sales invoice's update_current_stock ran one bin query per item row and one
per packed row. delivery note already batched the same work by warehouse, so
lift that into get_bin_qty_map in stock/utils.py and have both call it.

also batch the per-batch expiry_date lookup in get_batches_by_oldest, and drop
three now-unused per-row setters: delivery note's set_actual_qty (already dead
before this change), sales invoice item's set_actual_qty and packed item's
set_actual_and_projected_qty.
This commit is contained in:
pandiyan
2026-08-11 15:40:23 +05:30
parent b5a3815a64
commit 6bf00d3c8b
6 changed files with 52 additions and 47 deletions

View File

@@ -29,6 +29,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
from erpnext.stock.doctype.delivery_note.services.billing_status import ( from erpnext.stock.doctype.delivery_note.services.billing_status import (
update_billed_amount_based_on_so, update_billed_amount_based_on_so,
) )
from erpnext.stock.utils import get_bin_qty_map
from .services.fixed_assets import FixedAssetService from .services.fixed_assets import FixedAssetService
from .services.inter_company import ( from .services.inter_company import (
@@ -991,11 +992,17 @@ class SalesInvoice(SellingController):
) )
def update_current_stock(self): def update_current_stock(self):
bin_qty_map = get_bin_qty_map(self.items + self.packed_items)
for item in self.items: for item in self.items:
item.set_actual_qty() if item.item_code and item.warehouse:
bin_data = bin_qty_map.get((item.item_code, item.warehouse))
item.actual_qty = bin_data.actual_qty if bin_data else 0
for packed_item in self.packed_items: for packed_item in self.packed_items:
packed_item.set_actual_and_projected_qty() bin_data = bin_qty_map.get((packed_item.item_code, packed_item.warehouse))
packed_item.actual_qty = bin_data.actual_qty if bin_data else 0
packed_item.projected_qty = bin_data.projected_qty if bin_data else 0
def update_packing_list(self): def update_packing_list(self):
if cint(self.update_stock) == 1: if cint(self.update_stock) == 1:

View File

@@ -114,15 +114,6 @@ class SalesInvoiceItem(Document):
) )
) )
def set_actual_qty(self):
if self.item_code and self.warehouse:
self.actual_qty = (
frappe.db.get_value(
"Bin", {"item_code": self.item_code, "warehouse": self.warehouse}, "actual_qty"
)
or 0
)
def set_income_account_for_fixed_asset(self, company: str): def set_income_account_for_fixed_asset(self, company: str):
"""Set income account for fixed asset item based on company's disposal account and cost center.""" """Set income account for fixed asset item based on company's disposal account and cost center."""
if not self.is_fixed_asset: if not self.is_fixed_asset:

View File

@@ -296,7 +296,18 @@ def get_batch_qty(
def get_batches_by_oldest(item_code: str, warehouse: str): def get_batches_by_oldest(item_code: str, warehouse: str):
"""Returns the oldest batch and qty for the given item_code and warehouse""" """Returns the oldest batch and qty for the given item_code and warehouse"""
batches = get_batch_qty(item_code=item_code, warehouse=warehouse) batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches] if not batches:
return []
expiry_dates = dict(
frappe.get_all(
"Batch",
filters={"name": ["in", {batch.batch_no for batch in batches}]},
fields=["name", "expiry_date"],
as_list=True,
)
)
batches_dates = [[batch, expiry_dates.get(batch.batch_no)] for batch in batches]
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1])) batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates return batches_dates

View File

@@ -10,6 +10,7 @@ from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService
from erpnext.stock.doctype.delivery_note.services.packing import PackingService from erpnext.stock.doctype.delivery_note.services.packing import PackingService
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.utils import get_bin_qty_map
form_grid_templates = {"items": "templates/form_grid/item_grid.html"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -258,14 +259,6 @@ class DeliveryNote(SellingController):
super().before_print(settings) super().before_print(settings)
def set_actual_qty(self):
for d in self.get("items"):
if d.item_code and d.warehouse:
actual_qty = frappe.db.get_value(
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
)
d.actual_qty = flt(actual_qty) or 0
def so_required(self): def so_required(self):
"""check in manage account if sales order required or not""" """check in manage account if sales order required or not"""
if frappe.get_single_value("Selling Settings", "so_required") == "Yes": if frappe.get_single_value("Selling Settings", "so_required") == "Yes":
@@ -404,28 +397,14 @@ class DeliveryNote(SellingController):
if not (self.get("_action") and self._action != "update_after_submit"): if not (self.get("_action") and self._action != "update_after_submit"):
return return
warehouse_item_codes = {} bin_qty_map = get_bin_qty_map(self.get("items") + self.get("packed_items"))
for d in self.get("items") + self.get("packed_items"):
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
if not warehouse_item_codes:
return
bin_map = {}
for warehouse, item_codes in warehouse_item_codes.items():
for b in frappe.get_all(
"Bin",
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
fields=["item_code", "actual_qty", "projected_qty"],
):
bin_map[(b.item_code, warehouse)] = b
for d in self.get("items"): for d in self.get("items"):
bin_data = bin_map.get((d.item_code, d.warehouse)) bin_data = bin_qty_map.get((d.item_code, d.warehouse))
d.actual_qty = bin_data.actual_qty if bin_data else None d.actual_qty = bin_data.actual_qty if bin_data else None
for d in self.get("packed_items"): for d in self.get("packed_items"):
bin_data = bin_map.get((d.item_code, d.warehouse)) bin_data = bin_qty_map.get((d.item_code, d.warehouse))
if bin_data: if bin_data:
d.actual_qty = flt(bin_data.actual_qty) d.actual_qty = flt(bin_data.actual_qty)
d.projected_qty = flt(bin_data.projected_qty) d.projected_qty = flt(bin_data.projected_qty)

View File

@@ -56,16 +56,7 @@ class PackedItem(Document):
warehouse: DF.Link | None warehouse: DF.Link | None
# end: auto-generated types # end: auto-generated types
def set_actual_and_projected_qty(self): pass
"Set actual and projected qty based on warehouse and item_code"
_bin = frappe.db.get_value(
"Bin",
{"item_code": self.item_code, "warehouse": self.warehouse},
["actual_qty", "projected_qty"],
as_dict=True,
)
self.actual_qty = _bin.actual_qty if _bin else 0
self.projected_qty = _bin.projected_qty if _bin else 0
def make_packing_list(doc): def make_packing_list(doc):

View File

@@ -205,6 +205,32 @@ def get_latest_stock_balance():
return bin_map return bin_map
def get_bin_qty_map(rows) -> dict[tuple[str, str], frappe._dict]:
"""Map ``(item_code, warehouse)`` to its Bin's actual and projected qty.
Fetched in a single query so a document costs one query instead of one per
row. Rows without an item or warehouse are skipped, and item/warehouse pairs
with no Bin are absent from the map.
"""
item_codes = set()
warehouses = set()
for row in rows:
if row.item_code and row.warehouse:
item_codes.add(row.item_code)
warehouses.add(row.warehouse)
if not item_codes:
return {}
bins = frappe.get_all(
"Bin",
filters={"item_code": ["in", item_codes], "warehouse": ["in", warehouses]},
fields=["item_code", "warehouse", "actual_qty", "projected_qty"],
)
return {(bin.item_code, bin.warehouse): bin for bin in bins}
def get_bin(item_code, warehouse): def get_bin(item_code, warehouse):
bin = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) bin = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin: if not bin: