fix(manufacturing): attribute available materials by original item

add_materials_from_transfer() merged transfer rows of the same item and
warehouse into one bucket even when their original_item differed, keeping
only the first row's attribution. Return entries and Manufacture backflush
rows built from a merged bucket then charged the whole quantity to a single
requirement, so per-row returned_qty and net transfer coverage landed on
the wrong required item.

Buckets are now keyed by (item_code, warehouse, original_item). Manufacture
consumption rows carry no original_item, so consumption is distributed
across the item's buckets in transfer order; any residual lands on the last
bucket, preserving the previous net quantity. Serial and batch deductions
resolve the bucket that actually holds the serial no or batch.

Restore the pre-split-refactor guard that skipped zero-qty rows when
building entries from buckets (dropped in e4b5e6bd1e); without it a fully
consumed bucket would emit a zero-qty row and block submission.
This commit is contained in:
Mihir Kandoi
2026-08-13 12:04:43 +05:30
parent ce0cdfb492
commit bec7b29e08

View File

@@ -668,6 +668,8 @@ 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:
if row.get("original_item"):
item_args["original_item"] = row.original_item
@@ -760,7 +762,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:
@@ -790,20 +792,49 @@ 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)
self._deduct_consumed_qty(buckets, flt(row.qty))
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)
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)
for serial_no in _details.serial_nos:
self._get_serial_no_bucket(buckets, serial_no).serial_nos.remove(serial_no)
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 _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
consumed_qty -= deducted
holders[-1].batches[batch_no] -= consumed_qty
def add_additional_cost(self):
if not self.wo_doc: