mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-02 16:13:22 +00:00
fix(stock): allow partial raw material picking/transfer from work order
When creating a pick list for a work order with partially available stock, the resulting Material Transfer for Manufacture stock entry was setting fg_completed_qty = for_qty (= wo.qty), causing material_transferred_for_manufacturing to reach wo.qty after just one partial transfer and blocking further pick lists. Fix: - Set fg_completed_qty = 0 on stock entries created from pick lists so the old SUM(fg_completed_qty) path never fires prematurely - Recompute material_transferred_for_manufacturing after each transfer: use SUM(fg_completed_qty) when > 0 (direct entries / excess transfer), otherwise use min(transferred/required) × wo.qty (pick list flow) - Add _validate_no_excess_transfer for pick list entries (fg_completed_qty=0) to prevent transferring more than pending qty; skip for return entries and when backflush is based on Material Transferred for Manufacture - Remove the zero-qty prompt in pick list work_order trigger; skip the qty dialog in work_order.js when max transferable qty is already 0 - Hide fg_completed_qty field in Stock Entry for Material Transfer for Manufacture purpose since it is unused in that flow Fixes: #70713, #63846
This commit is contained in:
@@ -19,6 +19,7 @@ from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
|||||||
get_consumed_qty,
|
get_consumed_qty,
|
||||||
get_row_wise_serial_batch,
|
get_row_wise_serial_batch,
|
||||||
)
|
)
|
||||||
|
from erpnext.manufacturing.doctype.work_order.services.status import StatusService
|
||||||
from erpnext.stock.utils import get_bin, get_latest_stock_qty
|
from erpnext.stock.utils import get_bin, get_latest_stock_qty
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +147,38 @@ class RequiredItemsService:
|
|||||||
row, transferred_qty, row_wise_serial_batch
|
row, transferred_qty, row_wise_serial_batch
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||||
|
|
||||||
|
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||||
|
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||||
|
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||||
|
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||||
|
sum_fg_completed_qty = StatusService(self.doc).get_transferred_or_manufactured_qty(
|
||||||
|
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
|
||||||
|
)
|
||||||
|
if sum_fg_completed_qty:
|
||||||
|
self.doc.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||||
|
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||||
|
required_by_item = {}
|
||||||
|
for row in self.doc.required_items:
|
||||||
|
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||||
|
continue
|
||||||
|
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||||
|
|
||||||
|
if not required_by_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
min_fraction = min(
|
||||||
|
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||||
|
for item_code, required_qty in required_by_item.items()
|
||||||
|
)
|
||||||
|
min_fraction = min(min_fraction, 1.0)
|
||||||
|
material_transferred = min_fraction * flt(self.doc.qty)
|
||||||
|
self.doc.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||||
|
|
||||||
def update_returned_qty(self):
|
def update_returned_qty(self):
|
||||||
returned_dict = self._material_transfer_qty_by_item(is_return=1)
|
returned_dict = self._material_transfer_qty_by_item(is_return=1)
|
||||||
for row in self.doc.required_items:
|
for row in self.doc.required_items:
|
||||||
|
|||||||
@@ -1149,17 +1149,24 @@ erpnext.work_order = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||||
this.show_prompt_for_qty_input(frm, purpose)
|
const max = this.get_max_transferable_qty(frm, purpose);
|
||||||
.then((data) => {
|
|
||||||
return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", {
|
const get_pick_list = (for_qty) =>
|
||||||
|
frappe
|
||||||
|
.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", {
|
||||||
source_name: frm.doc.name,
|
source_name: frm.doc.name,
|
||||||
for_qty: data.qty,
|
for_qty: for_qty,
|
||||||
|
})
|
||||||
|
.then((pick_list) => {
|
||||||
|
frappe.model.sync(pick_list);
|
||||||
|
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.then((pick_list) => {
|
if (max <= 0) {
|
||||||
frappe.model.sync(pick_list);
|
get_pick_list(frm.doc.qty);
|
||||||
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
} else {
|
||||||
});
|
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
|
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
|||||||
stock_entry.from_bom = 1
|
stock_entry.from_bom = 1
|
||||||
stock_entry.bom_no = work_order.bom_no
|
stock_entry.bom_no = work_order.bom_no
|
||||||
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
|
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
|
||||||
stock_entry.fg_completed_qty = pick_list.for_qty
|
stock_entry.fg_completed_qty = 0
|
||||||
if work_order.bom_no:
|
if work_order.bom_no:
|
||||||
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
|
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
|
||||||
|
|
||||||
|
|||||||
@@ -182,13 +182,66 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry):
|
|||||||
self.validate_same_source_target_warehouse()
|
self.validate_same_source_target_warehouse()
|
||||||
|
|
||||||
def validate_component_and_quantities(self):
|
def validate_component_and_quantities(self):
|
||||||
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
|
if self.doc.fg_completed_qty:
|
||||||
|
if frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
|
||||||
|
_check_bom_component_qty(
|
||||||
|
self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
|
||||||
|
)
|
||||||
|
elif self.doc.work_order:
|
||||||
|
self._validate_no_excess_transfer()
|
||||||
|
|
||||||
|
def _validate_no_excess_transfer(self):
|
||||||
|
if self.doc.is_return:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.doc.fg_completed_qty:
|
if (
|
||||||
|
frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
|
||||||
|
== "Material Transferred for Manufacture"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
_check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom))
|
wo = self.wo_doc
|
||||||
|
if not wo:
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_by_item = {}
|
||||||
|
for r in wo.required_items:
|
||||||
|
pending_by_item[r.item_code] = (
|
||||||
|
pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty)
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_by_item = {}
|
||||||
|
first_row_by_item = {}
|
||||||
|
for item in self.doc.items:
|
||||||
|
if not item.s_warehouse:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (
|
||||||
|
item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None)
|
||||||
|
)
|
||||||
|
if key not in pending_by_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty)
|
||||||
|
first_row_by_item.setdefault(key, item)
|
||||||
|
|
||||||
|
for key, transfer_qty in transfer_by_item.items():
|
||||||
|
pending_qty = pending_by_item[key]
|
||||||
|
if transfer_qty > pending_qty:
|
||||||
|
item = first_row_by_item[key]
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
|
||||||
|
"Maximum transferable quantity is {4} {2}."
|
||||||
|
).format(
|
||||||
|
item.idx,
|
||||||
|
transfer_qty,
|
||||||
|
item.uom,
|
||||||
|
frappe.bold(item.item_code),
|
||||||
|
pending_qty,
|
||||||
|
),
|
||||||
|
title=_("Excess Material Transfer"),
|
||||||
|
)
|
||||||
|
|
||||||
def add_items(self):
|
def add_items(self):
|
||||||
item_dict = self.get_pending_raw_materials()
|
item_dict = self.get_pending_raw_materials()
|
||||||
|
|||||||
Reference in New Issue
Block a user