mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 15:38:39 +00:00
fix(manufacturing): preserve alternative material attribution (#58131)
This commit is contained in:
@@ -1690,6 +1690,73 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
terminal_work_order.reload()
|
||||
self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
|
||||
def test_return_attribution_when_item_doubles_as_alternative(self):
|
||||
"""An item transferred both for its own requirement and as an alternative for
|
||||
another requirement must return per attribution, not under one original_item."""
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 2)
|
||||
)
|
||||
for item in transfer_entry.items:
|
||||
if item.item_code == "_Test Item Home Desktop 100":
|
||||
item.item_code = "_Test Item"
|
||||
item.original_item = "_Test Item Home Desktop 100"
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
return_entry = make_stock_return_entry(work_order.name)
|
||||
return_entry.company = work_order.company
|
||||
rows_by_attribution = {row.original_item: row for row in return_entry.items}
|
||||
self.assertEqual(set(rows_by_attribution), {None, "_Test Item Home Desktop 100"})
|
||||
self.assertEqual(rows_by_attribution[None].qty, 2)
|
||||
self.assertEqual(rows_by_attribution["_Test Item Home Desktop 100"].qty, 4)
|
||||
|
||||
return_entry.remove(rows_by_attribution[None])
|
||||
return_entry.items[0].qty = 2
|
||||
return_entry.save()
|
||||
return_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
returned_by_item = {row.item_code: row.returned_qty for row in work_order.required_items}
|
||||
self.assertEqual(returned_by_item["_Test Item"], 0)
|
||||
self.assertEqual(returned_by_item["_Test Item Home Desktop 100"], 2)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings", {"backflush_raw_materials_based_on": "Material Transferred for Manufacture"}
|
||||
)
|
||||
def test_return_after_consumption_distributes_across_attributions(self):
|
||||
"""Manufacture consumption carries no original_item; it must drain attribution
|
||||
buckets in transfer order so the return entry reflects what remains."""
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 2)
|
||||
)
|
||||
for item in transfer_entry.items:
|
||||
if item.item_code == "_Test Item Home Desktop 100":
|
||||
item.item_code = "_Test Item"
|
||||
item.original_item = "_Test Item Home Desktop 100"
|
||||
transfer_entry.submit()
|
||||
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
|
||||
raw_material_rows = [row for row in manufacture_entry.items if row.s_warehouse]
|
||||
self.assertEqual(sorted(row.qty for row in raw_material_rows), [1.0, 2.0])
|
||||
manufacture_entry.submit()
|
||||
|
||||
return_entry = make_stock_return_entry(work_order.name)
|
||||
self.assertEqual(len(return_entry.items), 1)
|
||||
self.assertEqual(return_entry.items[0].original_item, "_Test Item Home Desktop 100")
|
||||
self.assertEqual(return_entry.items[0].qty, 3)
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred(self):
|
||||
"""Stock Entry created from a Pick List that picked only one of the required items:
|
||||
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
|
||||
|
||||
@@ -668,7 +668,10 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
|
||||
qty = row.qty if is_return else (flt(row.qty) * flt(self.doc.fg_completed_qty)) / pending_qty_to_mfg
|
||||
item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.uom)
|
||||
item_args["transfer_qty"] = item_args["qty"]
|
||||
if not flt(item_args["qty"], frappe.get_precision("Stock Entry Detail", "qty")):
|
||||
return
|
||||
if is_return:
|
||||
item_args["original_item"] = row.original_item
|
||||
item_args["s_warehouse"], item_args["t_warehouse"] = row.s_warehouse, row.t_warehouse
|
||||
else:
|
||||
item_args["t_warehouse"], item_args["s_warehouse"] = None, row.warehouse
|
||||
@@ -758,7 +761,7 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
|
||||
def add_materials_from_transfer(self):
|
||||
for row in self._transfer_entries:
|
||||
row.warehouse = row.t_warehouse
|
||||
key = (row.item_code, row.warehouse)
|
||||
key = (row.item_code, row.warehouse, row.original_item or None)
|
||||
if key not in self.available_materials:
|
||||
self.available_materials[key] = frappe._dict(row)
|
||||
else:
|
||||
@@ -788,20 +791,57 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
|
||||
def remove_consumed_materials_from_available(self):
|
||||
for row in self._consumption_entries:
|
||||
row.warehouse = row.s_warehouse
|
||||
key = (row.item_code, row.warehouse)
|
||||
self.available_materials[key].qty -= row.qty
|
||||
buckets = self._get_available_buckets(row.item_code, row.warehouse)
|
||||
if row.serial_and_batch_bundle:
|
||||
self._deduct_consumed_serial_batch(key, row.serial_and_batch_bundle)
|
||||
self._deduct_consumed_serial_batch(buckets, row.serial_and_batch_bundle)
|
||||
else:
|
||||
self._deduct_consumed_qty(buckets, flt(row.qty))
|
||||
|
||||
def _deduct_consumed_serial_batch(self, key, sabb_name):
|
||||
def _get_available_buckets(self, item_code, warehouse):
|
||||
return [
|
||||
bucket
|
||||
for key, bucket in self.available_materials.items()
|
||||
if key[0] == item_code and key[1] == warehouse
|
||||
]
|
||||
|
||||
def _deduct_consumed_qty(self, buckets, consumed_qty):
|
||||
for bucket in buckets[:-1]:
|
||||
deducted = min(max(flt(bucket.qty), 0.0), consumed_qty)
|
||||
bucket.qty -= deducted
|
||||
consumed_qty -= deducted
|
||||
buckets[-1].qty -= consumed_qty
|
||||
|
||||
def _deduct_consumed_serial_batch(self, buckets, sabb_name):
|
||||
_details = self.get_sabb_details(sabb_name)
|
||||
if _details.serial_nos:
|
||||
for sn in _details.serial_nos:
|
||||
self.available_materials[key].serial_nos.remove(sn)
|
||||
self._deduct_consumed_serial_nos(buckets, _details.serial_nos)
|
||||
elif _details.batches:
|
||||
for batch_no, qty in _details.batches.items():
|
||||
# qty is negative, so add instead of subtract
|
||||
self.available_materials[key].batches[batch_no] += qty
|
||||
self._deduct_consumed_batch_qty(buckets, batch_no, -qty)
|
||||
|
||||
def _deduct_consumed_serial_nos(self, buckets, serial_nos):
|
||||
for serial_no in serial_nos:
|
||||
bucket = self._get_serial_no_bucket(buckets, serial_no)
|
||||
bucket.serial_nos.remove(serial_no)
|
||||
bucket.qty -= 1
|
||||
|
||||
def _get_serial_no_bucket(self, buckets, serial_no):
|
||||
for bucket in buckets:
|
||||
if bucket.serial_nos and serial_no in bucket.serial_nos:
|
||||
return bucket
|
||||
return buckets[-1]
|
||||
|
||||
def _deduct_consumed_batch_qty(self, buckets, batch_no, consumed_qty):
|
||||
holders = [bucket for bucket in buckets if bucket.batches and batch_no in bucket.batches]
|
||||
if not holders:
|
||||
holders = buckets[-1:]
|
||||
for bucket in holders[:-1]:
|
||||
deducted = min(max(flt(bucket.batches[batch_no]), 0.0), consumed_qty)
|
||||
bucket.batches[batch_no] -= deducted
|
||||
bucket.qty -= deducted
|
||||
consumed_qty -= deducted
|
||||
holders[-1].batches[batch_no] -= consumed_qty
|
||||
holders[-1].qty -= consumed_qty
|
||||
|
||||
def add_additional_cost(self):
|
||||
if not self.wo_doc:
|
||||
|
||||
@@ -946,6 +946,26 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
service._validate_no_excess_transfer()
|
||||
|
||||
def test_tracked_consumption_deducts_matching_attribution_bucket(self):
|
||||
from erpnext.stock.doctype.stock_entry.services.manufacturing import ManufactureStockEntry
|
||||
|
||||
service = ManufactureStockEntry(frappe._dict())
|
||||
serial_buckets = [
|
||||
frappe._dict(qty=2, serial_nos=["SERIAL-1", "SERIAL-2"]),
|
||||
frappe._dict(qty=2, serial_nos=["SERIAL-3", "SERIAL-4"]),
|
||||
]
|
||||
service._deduct_consumed_serial_nos(serial_buckets, ["SERIAL-3"])
|
||||
self.assertEqual([bucket.qty for bucket in serial_buckets], [2, 1])
|
||||
self.assertEqual(serial_buckets[1].serial_nos, ["SERIAL-4"])
|
||||
|
||||
batch_buckets = [
|
||||
frappe._dict(qty=2, batches={"BATCH-1": 2}),
|
||||
frappe._dict(qty=3, batches={"BATCH-2": 3}),
|
||||
]
|
||||
service._deduct_consumed_batch_qty(batch_buckets, "BATCH-2", 1)
|
||||
self.assertEqual([bucket.qty for bucket in batch_buckets], [2, 2])
|
||||
self.assertEqual(batch_buckets[1].batches["BATCH-2"], 2)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
|
||||
def test_work_order_manufacture_with_material_consumption(self):
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import (
|
||||
|
||||
Reference in New Issue
Block a user