From bec7b29e0842b118036a5956b9d27bd91d27e9b7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 13 Aug 2026 12:04:43 +0530 Subject: [PATCH] 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. --- .../stock_entry/services/manufacturing.py | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index 0b5889824e1..f23206a71e1 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -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: