Merge pull request #55765 from rohitwaghchaure/fixed-github-55621

fix: Stock Reservation blocks Subcontracting operation within the same work order
This commit is contained in:
rohitwaghchaure
2026-06-09 17:43:47 +05:30
committed by GitHub
4 changed files with 173 additions and 0 deletions

View File

@@ -926,6 +926,9 @@ class JobCard(Document):
)
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(
@@ -940,6 +943,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)

View File

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

View File

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

View File

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