From 20ba97aa7d98017ce0dbbea55c8837b3761fb232 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 9 Jun 2026 16:21:05 +0530 Subject: [PATCH] fix: Stock Reservation blocks Subcontracting operation within the same Work Order --- .../doctype/job_card/job_card.py | 6 ++ .../work_order/services/stock_reservation.py | 62 +++++++++++++ .../doctype/work_order/test_work_order.py | 87 +++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 18 ++++ 4 files changed, 173 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8639a6769cd..e09b011034d 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -916,6 +916,9 @@ class JobCard(Document): self.validate_completed_qty_matches_for_quantity() def validate_time_logs_present(self): + if self.track_semi_finished_goods and self.is_subcontracted: + return + if not self.time_logs: frappe.throw( _("Time logs are required for {0} {1}").format( @@ -930,6 +933,9 @@ class JobCard(Document): ) def validate_completed_qty_matches_for_quantity(self): + if self.track_semi_finished_goods and self.is_subcontracted: + return + precision = self.precision("total_completed_qty") total_completed_qty = flt( flt(self.total_completed_qty, precision) diff --git a/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py index b698979f67e..e7b4c75a8c7 100644 --- a/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py +++ b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py @@ -433,6 +433,68 @@ class StockReservationService: if sre_list: unreserve_stock_for_work_order(self.doc, sre_list) + def release_reserved_qty_for_subcontract_transfer(self): + """Free this Work Order's own reservation for items sent to a subcontractor. + + A ``Send to Subcontractor`` Stock Entry raised against a Work Order consumes stock that + the same Work Order reserved (e.g. the semi-finished item of a subcontracted operation). + The sent qty is recorded as ``transferred_qty`` on the matching Stock Reservation Entries + so the negative-stock guard stops treating it as reserved for "other transactions". The + figure is recomputed from every submitted ``Send to Subcontractor`` entry for the Work + Order, so it self-corrects on cancellation / reposting. + + 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 + + entries = frappe.get_all( + "Stock Reservation Entry", + filters={"voucher_no": self.doc.name, "voucher_type": "Work Order", "docstatus": 1}, + fields=["name", "item_code", "warehouse", "reservation_based_on"], + order_by="creation", + ) + + for entry in entries: + if entry.reservation_based_on == "Serial and Batch": + continue + + key = (entry.item_code, entry.warehouse) + if key not in remaining: + 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.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. + 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) + 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")) + .where( + (ste.docstatus.isin([1, 2])) + & (ste.work_order == self.doc.name) + & (ste.purpose == "Send to Subcontractor") + & (ste_child.s_warehouse.isnotnull()) + ) + .groupby(ste_child.item_code, ste_child.s_warehouse) + ).run(as_dict=1) + return {(d.item_code, d.s_warehouse): flt(d.qty) for d in rows} + @frappe.whitelist() def make_stock_reservation_entries( diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bd93d233914..b88b283e312 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3417,6 +3417,93 @@ 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.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") + + bom = make_bom( + item=production_item, + source_warehouse=source_warehouse, + raw_materials=[rm_item], + operating_cost_per_bom_quantity=100, + do_not_submit=True, + ) + 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() + + wo = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + sre_name = frappe.db.get_value( + "Stock Reservation Entry", + {"voucher_no": wo.name, "item_code": rm_item, "warehouse": source_warehouse}, + ) + 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) + + # 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, + }, + ) + 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. + sre = frappe.get_doc("Stock Reservation Entry", sre_name) + self.assertEqual(sre.transferred_qty, 10) + self.assertEqual(sre.status, "Closed") + + # Cancelling the entry restores the reservation. + ste.cancel() + sre.reload() + self.assertEqual(sre.transferred_qty, 0) + self.assertEqual(sre.status, "Reserved") + def test_stock_reservation_for_batched_raw_material(self): from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( make_stock_entry as make_stock_entry_test_record, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 4404ab06947..84983879858 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -327,6 +327,9 @@ class StockEntry(StockController, SubcontractingInwardController): self.make_bundle_using_old_serial_batch_fields() self.adjust_stock_reservation_entries_for_return() self.update_stock_reservation_entries() + # Release the Work Order's own reservation for items being sent to the subcontractor + # before the negative-stock guard runs in update_stock_ledger(). + self.update_wo_reservation_for_subcontracting() self.update_stock_ledger() self.make_stock_reserve_for_wip_and_fg() self.reserve_stock_for_subcontracting() @@ -368,6 +371,8 @@ class StockEntry(StockController, SubcontractingInwardController): self.update_quality_inspection() self.adjust_stock_reservation_entries_for_return() self.update_stock_reservation_entries() + # Recompute (now excludes this cancelled entry) so the freed reservation is restored. + self.update_wo_reservation_for_subcontracting() self.delete_auto_created_batches() self.delete_linked_stock_entry() super().on_cancel_subcontracting_inward() @@ -1123,6 +1128,19 @@ class StockEntry(StockController, SubcontractingInwardController): return False + def update_wo_reservation_for_subcontracting(self): + 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) + StockReservationService(pro_doc).release_reserved_qty_for_subcontract_transfer() + @frappe.whitelist() def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False): item = self._fetch_item_data(args)