From c4fbc745db3dfe20abf248e0b814c47cad22de2c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 9 Jun 2026 20:32:13 +0530 Subject: [PATCH] fix: Stock Reservation blocks Subcontracting operation within the same Work Order --- .../work_order/services/stock_reservation.py | 62 ++++-- .../doctype/work_order/test_work_order.py | 195 +++++++++++++----- .../stock/doctype/stock_entry/stock_entry.py | 50 ++++- 3 files changed, 229 insertions(+), 78 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py index e7b4c75a8c7..a3539dd7c03 100644 --- a/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py +++ b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py @@ -446,9 +446,7 @@ class StockReservationService: Note: only qty-based reservations are handled here; serial/batch reservations are left to the existing material-transfer machinery. """ - remaining = self._subcontract_transferred_qty_by_item() - if not remaining: - return + sent = self._subcontract_transferred_qty_by_item() entries = frappe.get_all( "Stock Reservation Entry", @@ -462,34 +460,66 @@ class StockReservationService: continue key = (entry.item_code, entry.warehouse) - if key not in remaining: + sre = frappe.get_doc("Stock Reservation Entry", entry.name) + + # Cap at what is still reservable (qty not already delivered/consumed). Always set the + # value -- including back to 0 when nothing (or less) is now sent -- so cancelling a + # transfer restores the reservation. + available = flt(sre.reserved_qty) - flt(sre.consumed_qty) - flt(sre.delivered_qty) + qty_to_set = max(min(flt(sent.get(key, 0.0)), available), 0.0) + if key in sent: + sent[key] = flt(sent[key]) - qty_to_set + + if flt(sre.transferred_qty) == qty_to_set: continue - sre = frappe.get_doc("Stock Reservation Entry", entry.name) - qty_to_update = max(min(flt(remaining[key]), flt(sre.reserved_qty) - flt(sre.consumed_qty)), 0.0) - remaining[key] = flt(remaining[key]) - qty_to_update - - sre.db_set("transferred_qty", qty_to_update, update_modified=False) + sre.db_set("transferred_qty", qty_to_set, update_modified=False) sre.update_status() sre.update_reserved_stock_in_bin() def _subcontract_transferred_qty_by_item(self): - # Include cancelled (docstatus 2) entries so an item that was sent and then cancelled stays - # in the map (with a recomputed qty of 0), letting release_reserved_qty_for_subcontract_transfer - # reset its reservation. Only submitted (docstatus 1) rows contribute to the qty. + """Qty sent to subcontractors for this Work Order, keyed by (item_code, source warehouse). + + The transfer Stock Entries are linked to the Work Order through its subcontracted Job Cards + (Job Card -> Subcontracting Order / Purchase Order -> Send to Subcontractor entry), since the + entry itself does not retain ``work_order``. Only submitted (docstatus 1) entries contribute, + so a cancelled transfer drops out and the reservation is restored on the next recompute. + """ + job_cards = frappe.get_all( + "Job Card", filters={"work_order": self.doc.name, "is_subcontracted": 1}, pluck="name" + ) + if not job_cards: + return {} + + sco_names = frappe.get_all( + "Subcontracting Order Item", filters={"job_card": ["in", job_cards]}, pluck="parent" + ) + po_names = frappe.get_all( + "Purchase Order Item", filters={"job_card": ["in", job_cards]}, pluck="parent" + ) + if not sco_names and not po_names: + return {} + ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") - submitted_qty = Case().when(ste.docstatus == 1, ste_child.transfer_qty).else_(0) + + link = None + if sco_names: + link = ste.subcontracting_order.isin(list(set(sco_names))) + if po_names: + po_link = ste.purchase_order.isin(list(set(po_names))) + link = po_link if link is None else (link | po_link) + rows = ( frappe.qb.from_(ste) .inner_join(ste_child) .on(ste_child.parent == ste.name) - .select(ste_child.item_code, ste_child.s_warehouse, fn.Sum(submitted_qty).as_("qty")) + .select(ste_child.item_code, ste_child.s_warehouse, fn.Sum(ste_child.transfer_qty).as_("qty")) .where( - (ste.docstatus.isin([1, 2])) - & (ste.work_order == self.doc.name) + (ste.docstatus == 1) & (ste.purpose == "Send to Subcontractor") & (ste_child.s_warehouse.isnotnull()) + & link ) .groupby(ste_child.item_code, ste_child.s_warehouse) ).run(as_dict=1) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index b88b283e312..aec57252c23 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3417,80 +3417,163 @@ class TestWorkOrder(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, transfer_entry.submit) - @ERPNextTestSuite.change_settings( - "Buying Settings", - {"backflush_raw_materials_of_subcontract_based_on": "Material Transferred for Subcontract"}, - ) def test_send_to_subcontractor_can_consume_work_order_reserved_stock(self): - # Regression for #55756: a "Send to Subcontractor" Stock Entry raised against a Work Order - # must be allowed to consume stock that the *same* Work Order reserved. Before the fix the - # negative-stock guard treated the WO's own reservation as "reserved for other - # transactions" and blocked the entry. - # - # Backflush is set to "Material Transferred for Subcontract" so the subcontract-order - # validation is a no-op: the default "BOM" path dereferences a Subcontracting Order, which - # this standalone entry does not have. The transfer path only needs `subcontracted_item`. + from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + from erpnext.manufacturing.doctype.job_card.mapper import make_subcontracting_po from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( make_stock_entry as make_stock_entry_test_record, ) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse - production_item = make_item("Test S2S Reservation FG", {"is_stock_item": 1}).name - rm_item = make_item("Test S2S Reservation RM", {"is_stock_item": 1}).name - source_warehouse = "Stores - _TC" - supplier_warehouse = create_warehouse("Test S2S Supplier WH", company="_Test Company") + company = "_Test Company" + warehouse = "Stores - _TC" + supplier_warehouse = create_warehouse("Test S2S Supplier WH", company=company) - bom = make_bom( - item=production_item, - source_warehouse=source_warehouse, - raw_materials=[rm_item], - operating_cost_per_bom_quantity=100, - do_not_submit=True, + fabric = make_item("Test S2S Fabric", {"is_stock_item": 1}).name + stitched = make_item("Test S2S Stitched Shirt", {"is_stock_item": 1}).name + tshirt = make_item("Test S2S T-Shirt", {"is_stock_item": 1, "is_sub_contracted_item": 1}).name + service_item = make_item("Test S2S Ironing Service", {"is_stock_item": 0}).name + + # Semi-FG BOM: Stitched Shirt from Fabric. + sfg_bom = frappe.new_doc("BOM") + sfg_bom.company = company + sfg_bom.item = stitched + sfg_bom.quantity = 1 + sfg_bom.append("items", {"item_code": fabric, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + # Subcontracting BOM: how to make the final T-Shirt at the supplier (consuming Stitched Shirt). + tshirt_from_stitched = frappe.new_doc("BOM") + tshirt_from_stitched.company = company + tshirt_from_stitched.item = tshirt + tshirt_from_stitched.quantity = 1 + tshirt_from_stitched.append("items", {"item_code": stitched, "qty": 1}) + tshirt_from_stitched.insert() + tshirt_from_stitched.submit() + + if not frappe.db.exists("Subcontracting BOM", {"finished_good": tshirt}): + frappe.get_doc( + { + "doctype": "Subcontracting BOM", + "finished_good": tshirt, + "finished_good_qty": 1, + "service_item": service_item, + "service_item_qty": 1, + "finished_good_bom": tshirt_from_stitched.name, + "is_active": 1, + } + ).insert() + + if not frappe.db.exists("Workstation", "Test S2S Workstation"): + make_workstation(workstation="Test S2S Workstation", production_capacity=1) + for op in ("Test S2S Stitching", "Test S2S Ironing"): + if not frappe.db.exists("Operation", op): + make_operation(operation=op, workstation="Test S2S Workstation") + + # Final BOM for T-Shirt: internal Stitching op (produces Stitched Shirt) + subcontracted Ironing. + fg_bom = frappe.new_doc("BOM") + fg_bom.company = company + fg_bom.item = tshirt + fg_bom.quantity = 1 + fg_bom.with_operations = 1 + fg_bom.track_semi_finished_goods = 1 + fg_bom.append("items", {"item_code": fabric, "qty": 1}) + fg_bom.append( + "operations", + { + "operation": "Test S2S Stitching", + "workstation": "Test S2S Workstation", + "finished_good": stitched, + "finished_good_qty": 1, + "bom_no": sfg_bom.name, + "time_in_mins": 60, + "sequence_id": 1, + }, ) - for row in bom.exploded_items: - make_stock_entry_test_record( - item_code=row.item_code, target=source_warehouse, qty=10, basic_rate=100 - ) - bom.save() - bom.submit() + fg_bom.append( + "operations", + { + "operation": "Test S2S Ironing", + "workstation": "Test S2S Workstation", + "finished_good": tshirt, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "is_subcontracted": 1, + "bom_no": tshirt_from_stitched.name, + "time_in_mins": 60, + "sequence_id": 2, + }, + ) + fg_bom.append("items", {"item_code": stitched, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + make_stock_entry_test_record(item_code=fabric, target=warehouse, qty=10, basic_rate=100) wo = make_wo_order_test_record( - item=production_item, + production_item=tshirt, qty=10, + bom_no=fg_bom.name, reserve_stock=1, - source_warehouse=source_warehouse, + skip_transfer=1, + source_warehouse=warehouse, + wip_warehouse=warehouse, + fg_warehouse=warehouse, + do_not_save=True, ) + wo.operations[0].time_in_mins = 60 + wo.operations[1].time_in_mins = 60 + wo.save() + wo.submit() + + # Complete the internal Stitching job card -> Stitched Shirt is produced into WIP and reserved. + stitching_jc = frappe.get_doc( + "Job Card", + frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Stitching"}), + ) + stitching_jc.append( + "time_logs", + { + "from_time": "2024-01-01 08:00:00", + "to_time": "2024-01-01 09:00:00", + "completed_qty": stitching_jc.for_quantity, + }, + ) + stitching_jc.submit() + + manufacturing_entry = frappe.get_doc(stitching_jc.make_stock_entry_for_semi_fg_item()) + manufacturing_entry.submit() sre_name = frappe.db.get_value( "Stock Reservation Entry", - {"voucher_no": wo.name, "item_code": rm_item, "warehouse": source_warehouse}, + {"voucher_no": wo.name, "item_code": stitched, "warehouse": warehouse, "docstatus": 1}, ) - self.assertTrue(sre_name, "Work Order should have reserved the raw material") - self.assertEqual(frappe.db.get_value("Stock Reservation Entry", sre_name, "reserved_qty"), 10) + self.assertTrue(sre_name, "Work Order should have reserved the semi-finished good") - # Send the reserved raw material to the subcontractor against the same Work Order. - ste = frappe.new_doc("Stock Entry") - ste.purpose = "Send to Subcontractor" - ste.stock_entry_type = "Send to Subcontractor" - ste.company = "_Test Company" - ste.work_order = wo.name - ste.append( - "items", - { - "item_code": rm_item, - "qty": 10, - "uom": "Nos", - "stock_uom": "Nos", - "conversion_factor": 1, - "s_warehouse": source_warehouse, - "t_warehouse": supplier_warehouse, - "basic_rate": 100, - "subcontracted_item": production_item, - }, - ) + # Subcontract the Ironing operation: Job Card -> Subcontracting PO -> Subcontracting Order. + ironing_jc = frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Ironing"}) + po = frappe.get_doc(make_subcontracting_po(ironing_jc)) + po.supplier = "_Test Supplier" + po.supplier_warehouse = supplier_warehouse + po.schedule_date = nowdate() + for item in po.items: + item.schedule_date = nowdate() + po.insert() + po.submit() + + sco = make_subcontracting_order(po.name) + sco.supplier_warehouse = supplier_warehouse + for item in sco.supplied_items: + item.reserve_warehouse = warehouse + sco.insert() + sco.submit() + + # Transfer the reserved Stitched Shirt to the subcontractor. This must NOT raise + # NegativeStockError ("reserved for other transactions"). + ste = frappe.new_doc("Stock Entry").update(make_rm_stock_entry(sco.name)) ste.insert() - - # Must submit without raising NegativeStockError ("reserved for other transactions"). ste.submit() # The reservation is freed: transferred_qty == sent qty and the SRE is Closed. @@ -3498,7 +3581,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(sre.transferred_qty, 10) self.assertEqual(sre.status, "Closed") - # Cancelling the entry restores the reservation. + # Cancelling the transfer restores the reservation. ste.cancel() sre.reload() self.assertEqual(sre.transferred_qty, 0) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 84983879858..edcd829abf2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1129,18 +1129,56 @@ class StockEntry(StockController, SubcontractingInwardController): return False def update_wo_reservation_for_subcontracting(self): + # A "Send to Subcontractor" entry never keeps its `work_order` (validate clears it for this + # purpose), so the owning Work Order is derived from the Subcontracting Order / Purchase Order + # that raised the transfer. Each such Work Order that reserves stock gets its reservation for + # the sent items released, so the negative-stock guard stops blocking the consumption. from erpnext.manufacturing.doctype.work_order.services.stock_reservation import ( StockReservationService, ) - if ( - self.purpose == "Send to Subcontractor" - and self.work_order - and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") - ): - pro_doc = frappe.get_doc("Work Order", self.work_order) + if self.purpose != "Send to Subcontractor": + return + + for wo_name in self.get_reserved_work_orders_for_subcontracting(): + pro_doc = frappe.get_doc("Work Order", wo_name) StockReservationService(pro_doc).release_reserved_qty_for_subcontract_transfer() + def get_reserved_work_orders_for_subcontracting(self): + job_cards = set() + if self.subcontracting_order: + job_cards.update( + frappe.get_all( + "Subcontracting Order Item", + filters={"parent": self.subcontracting_order}, + pluck="job_card", + ) + ) + if self.purchase_order: + job_cards.update( + frappe.get_all( + "Purchase Order Item", filters={"parent": self.purchase_order}, pluck="job_card" + ) + ) + + job_cards = {jc for jc in job_cards if jc} + if not job_cards: + return [] + + work_orders = frappe.get_all( + "Job Card", filters={"name": ["in", list(job_cards)]}, pluck="work_order" + ) + + reserved_work_orders = [] + for work_order in set(work_orders): + if not work_order: + continue + + if frappe.get_cached_value("Work Order", work_order, "reserve_stock"): + reserved_work_orders.append(work_order) + + return reserved_work_orders + @frappe.whitelist() def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False): item = self._fetch_item_data(args)