From 87a472c2d7fd674ff1fcd19d3cbeb4e2a56f2054 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 24 Jun 2025 18:36:45 +0530 Subject: [PATCH] fix: multiple fixes related to stock reservation --- .../production_plan/production_plan.py | 10 +- .../production_plan/test_production_plan.py | 10 +- .../doctype/work_order/work_order.py | 4 +- .../stock_reservation_entry.json | 6 +- .../stock_reservation_entry.py | 199 +++++++++++++++--- 5 files changed, 181 insertions(+), 48 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 662bf884afb..c1ce112e3fd 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -899,6 +899,7 @@ class ProductionPlan(Document): try: wo.flags.ignore_mandatory = True wo.flags.ignore_validate = True + wo.company = self.company wo.insert() return wo.name except OverProductionError: @@ -1839,13 +1840,14 @@ def get_sub_assembly_items( bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) for _bin_dict in bin_details[d.item_code]: - if _bin_dict.projected_qty > 0: - if _bin_dict.projected_qty >= stock_qty: - _bin_dict.projected_qty -= stock_qty + _bin_dict.original_projected_qty = _bin_dict.projected_qty + if _bin_dict.original_projected_qty > 0: + if _bin_dict.original_projected_qty >= stock_qty: + _bin_dict.original_projected_qty -= stock_qty stock_qty = 0 continue else: - stock_qty = stock_qty - _bin_dict.projected_qty + stock_qty = stock_qty - _bin_dict.original_projected_qty sub_assembly_items.append(d.item_code) elif warehouse: bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5126c65e4cd..e29d06a4821 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2022,7 +2022,7 @@ class TestProductionPlan(IntegrationTestCase): else: # For raw materials 2 stock reservation entries # 5 qty was present already in stock and 5 added from new PO - self.assertEqual(len(reserved_entries), 2) + self.assertEqual(len(reserved_entries), 1) sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) @@ -2097,7 +2097,7 @@ class TestProductionPlan(IntegrationTestCase): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 6) + self.assertTrue(len(reserved_entries) == 30) for row in reserved_entries: self.assertEqual(row.reserved_qty, 5.0) @@ -2136,7 +2136,7 @@ class TestProductionPlan(IntegrationTestCase): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 9) + self.assertTrue(len(reserved_entries) == 45) serial_nos_res_for_pp = frappe.get_all( "Serial and Batch Entry", filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, @@ -2166,11 +2166,11 @@ class TestProductionPlan(IntegrationTestCase): self.assertFalse(serial_no in additional_serial_nos) if wo_doc.production_item == "Finished Good For SR": - self.assertEqual(len(reserved_entries), 3) + self.assertEqual(len(reserved_entries), 15) else: # For raw materials 2 stock reservation entries # 5 qty was present already in stock and 5 added from new PO - self.assertEqual(len(reserved_entries), 2) + self.assertEqual(len(reserved_entries), 10) sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 840fbaf7a69..8705231b101 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1247,7 +1247,9 @@ class WorkOrder(Document): "description": item.description, "allow_alternative_item": item.allow_alternative_item, "required_qty": item.qty, - "source_warehouse": (item.source_warehouse or item.default_warehouse) + "source_warehouse": ( + self.source_warehouse or item.source_warehouse or item.default_warehouse + ) if not reset_source_warehouse else self.source_warehouse, "include_item_in_manufacturing": item.include_item_in_manufacturing, diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 79837e5513f..e6fef3e0c87 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -93,7 +93,6 @@ "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "in_filter": 1, - "in_list_view": 1, "in_standard_filter": 1, "label": "Voucher No", "no_copy": 1, @@ -173,7 +172,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled", + "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled\nClosed", "read_only": 1 }, { @@ -345,7 +344,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-04-30 22:15:22.998138", + "modified": "2025-06-24 00:24:40.394164", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -455,5 +454,6 @@ "sort_field": "creation", "sort_order": "DESC", "states": [], + "title_field": "voucher_no", "track_changes": 1 } diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index cad29e6fd9c..a404d59febc 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1,11 +1,13 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import defaultdict from typing import Literal import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import Case from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, nowdate, nowtime, parse_json @@ -41,7 +43,13 @@ class StockReservationEntry(Document): reserved_qty: DF.Float sb_entries: DF.Table[SerialandBatchEntry] status: DF.Literal[ - "Draft", "Partially Reserved", "Reserved", "Partially Delivered", "Delivered", "Cancelled" + "Draft", + "Partially Reserved", + "Reserved", + "Partially Delivered", + "Delivered", + "Cancelled", + "Closed", ] stock_uom: DF.Link | None transferred_qty: DF.Float @@ -114,6 +122,11 @@ class StockReservationEntry(Document): index = 0 for row in sres: status = "Reserved" + + if self.has_batch_no or self.has_serial_no: + serial_batch_data = self.get_serial_batch_entries() + update_serial_batch_delivered_qty(serial_batch_data, row.name, is_cancelled=True) + if delivered_qty <= 0 or index == 0: frappe.db.set_value( "Stock Reservation Entry", @@ -143,6 +156,22 @@ class StockReservationEntry(Document): delivered_qty -= row.reserved_qty + def get_serial_batch_entries(self): + serial_nos = [] + batches = defaultdict(float) + for entry in self.sb_entries: + if entry.serial_no: + serial_nos.append(entry.serial_no) + elif entry.batch_no: + batches[entry.batch_no] += entry.qty + + return frappe._dict( + { + "serial_nos": serial_nos, + "batches": batches, + } + ) + def get_from_voucher_reservation_entries(self): return frappe.get_all( "Stock Reservation Entry", @@ -1235,7 +1264,6 @@ class StockReservation: return available_qty def transfer_reservation_entries_to(self, docnames, from_doctype, to_doctype): - delivery_qty_to_update = frappe._dict() if isinstance(docnames, str): docnames = [docnames] @@ -1247,54 +1275,88 @@ class StockReservation: if not reservation_entries: return - entries_to_reserve = [] + entries_to_reserve = frappe._dict({}) for row in reservation_entries: + reserved_qty_field = "reserved_qty" if row.reservation_based_on == "Qty" else "sabb_qty" + delivered_qty_field = ( + "delivered_qty" if row.reservation_based_on == "Qty" else "sabb_delivered_qty" + ) + available_qty = row.get(reserved_qty_field) - row.get(delivered_qty_field) + for entry in items_to_reserve: if not ( row.item_code == entry.item_code and row.warehouse == entry.warehouse and entry.qty > 0 ): continue - available_qty = row.reserved_qty - row.delivered_qty if available_qty <= 0: continue + key = (row.item_code, row.warehouse) + + if key not in entries_to_reserve: + entries_to_reserve.setdefault( + key, + frappe._dict( + { + "qty_to_reserve": 0.0, + "item_code": row.item_code, + "warehouse": row.warehouse, + "voucher_type": entry.voucher_type, + "voucher_no": entry.voucher_no, + "voucher_detail_no": entry.voucher_detail_no, + "serial_nos": [], + "sre_names": defaultdict(float), + "batches": defaultdict(float), + "against_row": row, + "company": self.doc.company, + } + ), + ) + # transfer qty if available_qty > entry.qty: qty_to_reserve = entry.qty - row.delivered_qty += available_qty - entry.qty - delivery_qty_to_update.setdefault(row.name, row.delivered_qty) else: qty_to_reserve = available_qty - row.delivered_qty += qty_to_reserve - delivery_qty_to_update.setdefault(row.name, row.delivered_qty) - - entries_to_reserve.append([entry, row, qty_to_reserve]) + available_qty -= qty_to_reserve entry.qty -= qty_to_reserve - if delivery_qty_to_update: - self.update_delivered_qty(delivery_qty_to_update) + entries_to_reserve[key]["qty_to_reserve"] += qty_to_reserve + if row.has_batch_no: + entries_to_reserve[key]["batches"][row.batch_no] += qty_to_reserve - for entry, row, qty_to_reserve in entries_to_reserve: - self.make_stock_reservation_entry(entry, row, qty_to_reserve) + if row.has_serial_no: + entries_to_reserve[key]["serial_nos"].append(row.serial_no) - def update_delivered_qty(self, delivery_qty_to_update): - for name, delivered_qty in delivery_qty_to_update.items(): + if row.name: + entries_to_reserve[key]["sre_names"][row.name] += qty_to_reserve + + for key in entries_to_reserve: + data = entries_to_reserve[key] + self.update_delivered_qty(data) + self.make_stock_reservation_entry(data) + + def update_delivered_qty(self, data): + for name, delivered_qty in data.get("sre_names").items(): doctype = frappe.qb.DocType("Stock Reservation Entry") query = ( frappe.qb.update(doctype) - .set(doctype.delivered_qty, delivered_qty) + .set(doctype.delivered_qty, doctype.delivered_qty + delivered_qty) .set( doctype.status, - "Delivered" if doctype.reserved_qty == doctype.delivered_qty else "Reserved", + Case().when((doctype.reserved_qty == doctype.delivered_qty), "Closed").else_("Reserved"), ) .where(doctype.name == name) ) query.run() - def make_stock_reservation_entry(self, row, against_row, reserved_qty): + if data.serial_nos or data.batches: + update_serial_batch_delivered_qty(data, name) + + def make_stock_reservation_entry(self, row): fields = [ "item_code", "warehouse", @@ -1309,9 +1371,11 @@ class StockReservation: for row_field in fields: sre.set(row_field, row.get(row_field)) - sre.available_qty = reserved_qty - sre.reserved_qty = reserved_qty - sre.voucher_qty = row.required_qty + sre.available_qty = row.get("qty_to_reserve") + sre.reserved_qty = row.get("qty_to_reserve") + sre.voucher_qty = row.get("qty_to_reserve") + + against_row = row.get("against_row") sre.from_voucher_no = against_row.voucher_no sre.from_voucher_detail_no = against_row.voucher_detail_no sre.from_voucher_type = against_row.voucher_type @@ -1319,26 +1383,68 @@ class StockReservation: sre.has_serial_no = against_row.has_serial_no sre.has_batch_no = against_row.has_batch_no - bundles = [against_row.name] - if row.serial_and_batch_bundles: - bundles = row.serial_and_batch_bundles + if row.serial_nos: + for serial_no in row.serial_nos: + batch_no = None + if row.batches: + batch_no = frappe.db.get_value("Serial No", serial_no, "batch_no") + + sre.append( + "sb_entries", + {"serial_no": serial_no, "warehouse": row.warehouse, "batch_no": batch_no, "qty": 1}, + ) + + elif row.batches: + for batch_no, qty in row.batches.items(): + sre.append( + "sb_entries", + {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty}, + ) - self.set_serial_batch(sre, bundles) sre.save() sre.submit() def get_reserved_entries(self, doctype, docnames): - filters = { - "docstatus": 1, - "status": ("not in", ["Delivered", "Cancelled", "Draft"]), - "voucher_type": doctype, - "voucher_no": docnames, - } + if isinstance(docnames, str): + docnames = [docnames] - if isinstance(docnames, list): - filters["voucher_no"] = ("in", docnames) + sre = frappe.qb.DocType("Stock Reservation Entry") + sabb_entry = frappe.qb.DocType("Serial and Batch Entry") - return frappe.get_all("Stock Reservation Entry", fields=["*"], filters=filters) + query = ( + frappe.qb.from_(sre) + .left_join(sabb_entry) + .on(sre.name == sabb_entry.parent) + .select( + sre.name, + sre.item_code, + sre.warehouse, + sre.voucher_type, + sre.voucher_no, + sre.voucher_detail_no, + sre.reserved_qty, + sre.delivered_qty, + sre.transferred_qty, + sre.consumed_qty, + sre.has_serial_no, + sre.has_batch_no, + sre.reservation_based_on, + sabb_entry.serial_no, + sabb_entry.batch_no, + sabb_entry.qty.as_("sabb_qty"), + sabb_entry.delivered_qty.as_("sabb_delivered_qty"), + ) + .where( + (sre.docstatus == 1) + & (sre.status.notin(["Delivered", "Cancelled", "Draft", "Closed"])) + & (sre.voucher_type == doctype) + & (sre.voucher_no.isin(docnames)) + ) + .orderby(sre.creation) + .orderby(sabb_entry.idx) + ) + + return query.run(as_dict=True) def get_items_to_reserve(self, docnames, from_doctype, to_doctype): field = frappe.scrub(from_doctype) @@ -1688,3 +1794,26 @@ def get_stock_reservation_entries_for_voucher( query = query.where(sre.status.notin(["Delivered", "Cancelled"])) return query.run(as_dict=True) + + +def update_serial_batch_delivered_qty(row, name, is_cancelled=False): + if row.serial_nos: + doctype = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.update(doctype) + .set(doctype.delivered_qty, (doctype.delivered_qty + (1 if not is_cancelled else -1))) + .where((doctype.parent == name) & (doctype.serial_no.isin(row.serial_nos))) + ) + + query.run() + + elif row.batches: + for batch_no, qty in row.batches.items(): + doctype = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.update(doctype) + .set(doctype.delivered_qty, (doctype.delivered_qty + (qty if not is_cancelled else -1 * qty))) + .where((doctype.parent == name) & (doctype.batch_no == batch_no)) + ) + + query.run()