mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-12 22:21:50 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user