Merge pull request #55778 from rohitwaghchaure/fixed-github-55621-develop

fix: Stock Reservation blocks Subcontracting operation within the same work order
This commit is contained in:
rohitwaghchaure
2026-06-09 21:06:10 +05:30
committed by GitHub
3 changed files with 229 additions and 78 deletions

View File

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

View File

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

View File

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