fix(work order): exclude corrective transfers from item-level transferred qty (#58091)

This commit is contained in:
Mihir Kandoi
2026-08-12 16:41:12 +05:30
committed by GitHub
parent e5a0b5f61e
commit 43de54b907
2 changed files with 111 additions and 0 deletions

View File

@@ -678,6 +678,93 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(corrective_job_card.get_onload("backflush_raw_materials_based_on"), "BOM")
self.assertEqual(corrective_job_card.get_onload("transfer_material_against"), "Job Card")
def test_corrective_job_card_transfer_excluded_from_item_transferred_qty(self):
from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_return_entry,
)
wo = make_wo_order_test_record(
item="_Test FG Item 2",
qty=4,
transfer_material_against="Work Order",
source_warehouse=self.source_warehouse,
)
self.generate_required_stock(wo)
transfer = frappe.get_doc(
make_stock_entry_for_wo(wo.name, "Material Transfer for Manufacture", qty=2)
)
transfer.fg_completed_qty = 0
transfer.submit()
job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name})
job_card.append(
"time_logs",
{"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 4},
)
job_card.submit()
corrective_operation = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_job_card = make_corrective_job_card(
job_card.name, operation=corrective_operation.name, for_operation=job_card.operation
)
corrective_job_card.for_quantity = 1
corrective_item = create_item(f"Corrective Item {frappe.generate_hash(length=8)}")
corrective_source_warehouse = wo.required_items[0].source_warehouse
make_stock_entry(
item_code=corrective_item.name,
target=corrective_source_warehouse,
qty=1,
basic_rate=100,
)
for row in wo.required_items:
corrective_job_card.append(
"items",
{
"item_code": row.item_code,
"source_warehouse": row.source_warehouse,
"uom": frappe.db.get_value("Item", row.item_code, "stock_uom"),
"required_qty": flt(row.required_qty) / 4,
},
)
corrective_job_card.append(
"items",
{
"item_code": corrective_item.name,
"source_warehouse": corrective_source_warehouse,
"uom": corrective_item.stock_uom,
"required_qty": 1,
},
)
corrective_job_card.insert()
corrective_transfer = make_stock_entry_from_jc(corrective_job_card.name)
corrective_transfer.submit()
wo.reload()
self.assertNotIn(corrective_item.name, [row.item_code for row in wo.required_items])
for row in wo.required_items:
self.assertEqual(flt(row.transferred_qty), flt(row.required_qty) / 2)
stock_return = make_stock_return_entry(wo.name)
stock_return.company = wo.company
returned_by_item = {
row.item_code: flt(row.transfer_qty) for row in stock_return.items if row.item_code
}
self.assertEqual(returned_by_item[corrective_item.name], 1)
for row in wo.required_items:
self.assertGreater(returned_by_item[row.item_code], flt(row.transferred_qty))
stock_return.submit()
wo.reload()
for row in wo.required_items:
self.assertEqual(flt(row.returned_qty), flt(row.transferred_qty))
@ERPNextTestSuite.change_settings(
"Manufacturing Settings",
{

View File

@@ -293,10 +293,13 @@ class RequiredItemsService:
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
job_card = frappe.qb.DocType("Job Card")
query = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.left_join(job_card)
.on(ste.job_card == job_card.name)
# original_item becomes the output dict key below, so it must stay coherent per row: the
# same item_code can be transferred both for itself (original_item NULL) and as a substitute
# for another required item (original_item set). Max() over a single item_code group could
@@ -309,14 +312,30 @@ class RequiredItemsService:
fn.Sum(ste_child.transfer_qty).as_("qty"),
)
.where(self._material_transfer_filter(ste, is_return))
.where(fn.Coalesce(job_card.is_corrective_job_card, 0) == 0)
.groupby(ste_child.item_code, ste_child.original_item)
)
qty_by_item = frappe._dict()
for d in query.run(as_dict=1) or []:
key = d.original_item or d.item_code
qty_by_item[key] = (qty_by_item.get(key) or 0.0) + flt(d.qty)
if is_return:
return self._cap_returned_qty_to_transferred(qty_by_item)
return qty_by_item
def _cap_returned_qty_to_transferred(self, returned_qty_by_item):
# Work Order returns combine regular and corrective stock without a Job Card link.
# Cap each return at the regular transfer total so corrective quantities stay neutral.
transferred_qty_by_item = self._material_transfer_qty_by_item(is_return=0)
return frappe._dict(
{
item_code: min(flt(returned_qty), flt(transferred_qty_by_item.get(item_code)))
for item_code, returned_qty in returned_qty_by_item.items()
}
)
def _material_transfer_filter(self, ste, is_return):
return (
(ste.docstatus == 1)
@@ -359,6 +378,11 @@ class RequiredItemsService:
if stock_entry.purpose != "Material Transfer for Manufacture":
return
if stock_entry.job_card and frappe.get_cached_value(
"Job Card", stock_entry.job_card, "is_corrective_job_card"
):
return
additional_items = self._additional_items_by_code(stock_entry)
self.doc.flags.ignore_validate_update_after_submit = True
for rows in additional_items.values():