mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-16 18:24:10 +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):
|
def validate_time_logs_present(self):
|
||||||
|
if self.track_semi_finished_goods and self.is_subcontracted:
|
||||||
|
return
|
||||||
|
|
||||||
if not self.time_logs:
|
if not self.time_logs:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Time logs are required for {0} {1}").format(
|
_("Time logs are required for {0} {1}").format(
|
||||||
@@ -940,6 +943,9 @@ class JobCard(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_completed_qty_matches_for_quantity(self):
|
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")
|
precision = self.precision("total_completed_qty")
|
||||||
total_completed_qty = flt(
|
total_completed_qty = flt(
|
||||||
flt(self.total_completed_qty, precision)
|
flt(self.total_completed_qty, precision)
|
||||||
|
|||||||
@@ -433,6 +433,68 @@ class StockReservationService:
|
|||||||
if sre_list:
|
if sre_list:
|
||||||
unreserve_stock_for_work_order(self.doc, 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()
|
@frappe.whitelist()
|
||||||
def make_stock_reservation_entries(
|
def make_stock_reservation_entries(
|
||||||
|
|||||||
@@ -3417,6 +3417,93 @@ class TestWorkOrder(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
|
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):
|
def test_stock_reservation_for_batched_raw_material(self):
|
||||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||||
make_stock_entry as make_stock_entry_test_record,
|
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.make_bundle_using_old_serial_batch_fields()
|
||||||
self.adjust_stock_reservation_entries_for_return()
|
self.adjust_stock_reservation_entries_for_return()
|
||||||
self.update_stock_reservation_entries()
|
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.update_stock_ledger()
|
||||||
self.make_stock_reserve_for_wip_and_fg()
|
self.make_stock_reserve_for_wip_and_fg()
|
||||||
self.reserve_stock_for_subcontracting()
|
self.reserve_stock_for_subcontracting()
|
||||||
@@ -368,6 +371,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
self.update_quality_inspection()
|
self.update_quality_inspection()
|
||||||
self.adjust_stock_reservation_entries_for_return()
|
self.adjust_stock_reservation_entries_for_return()
|
||||||
self.update_stock_reservation_entries()
|
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_auto_created_batches()
|
||||||
self.delete_linked_stock_entry()
|
self.delete_linked_stock_entry()
|
||||||
super().on_cancel_subcontracting_inward()
|
super().on_cancel_subcontracting_inward()
|
||||||
@@ -1123,6 +1128,19 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
|
|
||||||
return False
|
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()
|
@frappe.whitelist()
|
||||||
def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False):
|
def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False):
|
||||||
item = self._fetch_item_data(args)
|
item = self._fetch_item_data(args)
|
||||||
|
|||||||
Reference in New Issue
Block a user