fix: exclude transferred_qty from work order item to pick list item mapping

get_mapped_doc copies same-named fields by default. work order item's
transferred_qty (cumulative across the whole work order) was leaking into
the new pick list item's transferred_qty (meant to track how much of
that pick list row has been converted into a stock entry, starting at 0).

the leaked value then got subtracted again in
get_pending_transfer_stock_qty(), so every pick list after the first
under-transferred raw materials by whatever was already recorded on the
work order, driving material_transferred_for_manufacturing towards zero
across repeated partial pick-list/finish cycles.

fixes #57236, related to #56596

(cherry picked from commit 5b36f12596)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/mapper.py
This commit is contained in:
pandiyan
2026-07-18 13:36:48 +05:30
committed by Mergify
parent 406d3b3862
commit 2519a91485

View File

@@ -0,0 +1,575 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Document-mapping and creation helpers for Work Order.
These functions build related documents (Work Order, Stock Entry, Job Card,
Pick List) from a Work Order. They were extracted from work_order.py to slim
the controller; work_order.py re-exports them for backward compatibility.
"""
import json
from functools import partial
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, get_link_to_form, nowdate
from erpnext.manufacturing.doctype.bom.bom import get_bom_item_rate
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@frappe.whitelist()
def get_item_details(item: str, project: str | None = None, skip_bom_info: bool = False, throw: bool = True):
frappe.has_permission("Item", "read", throw=True)
res = _item_master_details(item)
if not res:
return {}
if skip_bom_info:
return res
res["bom_no"] = _default_bom_for_item(item, project)
if not res["bom_no"]:
return _handle_missing_default_bom(res, item, project, throw)
_merge_bom_details(res, project)
return res
def _item_master_details(item):
item_table = frappe.qb.DocType("Item")
res = (
frappe.qb.from_(item_table)
.select(
item_table.stock_uom,
item_table.description,
item_table.item_name,
item_table.allow_alternative_item,
item_table.include_item_in_manufacturing,
)
.where((item_table.disabled == 0) & (item_table.name == item) & _item_is_alive(item_table))
).run(as_dict=1)
return res[0] if res else {}
def _item_is_alive(item_table):
# "not set" end_of_life is NULL on postgres (the MariaDB zero-date '0000-00-00' is an invalid
# date constant there), so only add the zero-date term on MariaDB.
is_alive = item_table.end_of_life.isnull() | (item_table.end_of_life > nowdate())
if frappe.db.db_type != "postgres":
is_alive |= item_table.end_of_life == "0000-00-00"
return is_alive
def _default_bom_for_item(item, project):
filters = (
{"item": item, "project": project} if project else {"item": item, "is_default": 1, "docstatus": 1}
)
bom_no = frappe.db.get_value("BOM", filters=filters)
if bom_no:
return bom_no
variant_of = frappe.db.get_value("Item", item, "variant_of")
return frappe.db.get_value("BOM", {"item": variant_of, "is_default": 1}) if variant_of else None
def _handle_missing_default_bom(res, item, project, throw):
if project:
res = get_item_details(item, throw=throw)
frappe.msgprint(
_("Default BOM not found for Item {0} and Project {1}").format(item, project), alert=1
)
return res
msg = _("Default BOM for {0} not found").format(item)
frappe.msgprint(msg, raise_exception=throw, indicator="yellow", alert=(not throw))
return res
def _merge_bom_details(res, project):
bom_data = frappe.db.get_value(
"BOM",
res["bom_no"],
["project", "allow_alternative_item", "transfer_material_against", "item_name"],
as_dict=1,
)
res["project"] = project or bom_data.pop("project")
res.update(bom_data)
res.update(check_if_scrap_warehouse_mandatory(res["bom_no"]))
@frappe.whitelist()
def make_work_order(
bom_no: str,
item: str,
qty: float = 0,
company: str | None = None,
project: str | None = None,
variant_items: str | list | None = None,
use_multi_level_bom: bool | None = None,
):
if not frappe.has_permission("Work Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
item_details = get_item_details(item, project)
bom_no = _variant_default_bom(item) or bom_no
wo_doc = _new_work_order(item, bom_no, company, item_details, use_multi_level_bom)
if flt(qty) > 0:
wo_doc.qty = flt(qty)
wo_doc.get_items_and_operations_from_bom()
if variant_items and not wo_doc.use_multi_level_bom:
add_variant_item(variant_items, wo_doc, bom_no, "required_items")
return wo_doc
def _variant_default_bom(item):
if not frappe.db.get_value("Item", item, "variant_of"):
return None
return frappe.db.get_value("BOM", {"item": item, "is_default": 1, "docstatus": 1})
def _new_work_order(item, bom_no, company, item_details, use_multi_level_bom):
from erpnext import get_default_company
wo_doc = frappe.new_doc("Work Order")
wo_doc.track_semi_finished_goods = frappe.db.get_value("BOM", bom_no, "track_semi_finished_goods")
wo_doc.production_item = item
wo_doc.company = company or get_default_company()
wo_doc.update(item_details)
wo_doc.bom_no = bom_no
wo_doc.use_multi_level_bom = cint(use_multi_level_bom)
return wo_doc
def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"):
variant_items = frappe.parse_json(variant_items)
for item in variant_items:
_add_variant_row(item, wo_doc, bom_no, table_name)
def _add_variant_row(item, wo_doc, bom_no, table_name):
bom_doc = frappe.get_cached_doc("BOM", bom_no)
args = _variant_item_args(item, wo_doc, bom_doc)
existing_row = (
get_template_rm_item(wo_doc, item.get("item_code")) if table_name == "required_items" else None
)
if existing_row:
existing_row.update(args)
else:
wo_doc.append(table_name, args)
def _variant_item_args(item, wo_doc, bom_doc):
args = frappe._dict(
item_code=item.get("variant_item_code"),
required_qty=item.get("qty"),
qty=item.get("qty"), # for bom
source_warehouse=item.get("source_warehouse"),
operation=item.get("operation"),
)
item_data = get_item_details(args.item_code, skip_bom_info=True)
args.update(item_data)
args["rate"] = _variant_item_rate(args, wo_doc, bom_doc)
if not args.source_warehouse:
default = get_item_defaults(item.get("variant_item_code"), wo_doc.company)
args["source_warehouse"] = default.default_warehouse
args["amount"] = flt(args.get("required_qty")) * flt(args.get("rate"))
args["uom"] = item_data.stock_uom
return args
def _variant_item_rate(args, wo_doc, bom_doc):
return get_bom_item_rate(
{
"company": wo_doc.company,
"item_code": args.get("item_code"),
"qty": args.get("required_qty"),
"uom": args.get("stock_uom"),
"stock_uom": args.get("stock_uom"),
"conversion_factor": 1,
},
bom_doc,
)
def get_template_rm_item(wo_doc, item_code):
for row in wo_doc.required_items:
if row.item_code == item_code:
return row
@frappe.whitelist()
def check_if_scrap_warehouse_mandatory(bom_no: str):
frappe.has_permission("BOM", "read", throw=True)
res = {"set_scrap_wh_mandatory": False}
if bom_no:
bom = frappe.get_doc("BOM", bom_no)
if bom.has_scrap_items():
res["set_scrap_wh_mandatory"] = True
return res
@frappe.whitelist()
def make_stock_entry(
work_order_id: str,
purpose: str,
qty: float | None = None,
target_warehouse: str | None = None,
is_additional_transfer_entry: bool = False,
source_stock_entry: str | None = None,
):
frappe.has_permission("Stock Entry", "create", throw=True)
work_order = frappe.get_doc("Work Order", work_order_id)
stock_entry = _new_manufacture_stock_entry(work_order, purpose, qty)
_set_stock_entry_warehouses(stock_entry, work_order, purpose, target_warehouse, source_stock_entry)
stock_entry.set_stock_entry_type()
stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items()
return stock_entry.as_dict()
def _new_manufacture_stock_entry(work_order, purpose, qty):
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.purpose = purpose
stock_entry.work_order = work_order.name
stock_entry.company = work_order.company
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
if purpose in ["Material Transfer for Manufacture", "Manufacture"]:
stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order
# accept 0 qty as well
stock_entry.fg_completed_qty = (
qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
)
return stock_entry
def _set_stock_entry_warehouses(stock_entry, work_order, purpose, target_warehouse, source_stock_entry):
is_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group")
wip_warehouse = None if is_group else work_order.wip_warehouse
stock_entry.project = work_order.project
if purpose == "Material Transfer for Manufacture":
stock_entry.to_warehouse = wip_warehouse
else:
skip = work_order.skip_transfer and not work_order.from_wip_warehouse
stock_entry.from_warehouse = work_order.source_warehouse if skip else wip_warehouse
stock_entry.to_warehouse = work_order.fg_warehouse
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value(
"BOM", work_order.bom_no, "inspection_required"
)
if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_warehouse
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
if source_stock_entry:
stock_entry.source_stock_entry = source_stock_entry
@frappe.whitelist()
def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None):
frappe.has_permission("Job Card", "create", throw=True)
operations = frappe.parse_json(operations)
work_order = frappe.get_doc("Work Order", work_order)
for row in operations:
row = frappe._dict(row)
row.update(get_operation_details(row.name, work_order, parent_bom))
validate_operation_data(row)
qty = row.get("qty")
while qty > 0:
qty = split_qty_based_on_batch_size(work_order, row, qty)
if row.job_card_qty > 0:
create_job_card(work_order, row, auto_create=True)
def get_operation_details(name, work_order, parent_bom):
for row in work_order.operations:
if row.name == name:
return {
"workstation": row.workstation,
"workstation_type": row.workstation_type,
"source_warehouse": row.source_warehouse,
"fg_warehouse": row.fg_warehouse,
"wip_warehouse": row.wip_warehouse,
"finished_good": row.finished_good,
"bom_no": row.get("bom_no") or parent_bom,
"is_subcontracted": row.get("is_subcontracted"),
}
def split_qty_based_on_batch_size(wo_doc, row, qty):
if not cint(frappe.db.get_value("Operation", row.operation, "create_job_card_based_on_batch_size")):
row.batch_size = row.get("qty") or wo_doc.qty
row.job_card_qty = row.batch_size
if row.batch_size and qty >= row.batch_size:
qty -= row.batch_size
elif qty > 0:
row.job_card_qty = qty
qty = 0
get_serial_nos_for_job_card(row, wo_doc)
return qty
def get_serial_nos_for_job_card(row, wo_doc):
if not wo_doc.has_serial_no:
return
serial_nos = get_serial_nos_for_work_order(wo_doc.name, wo_doc.production_item)
used_serial_nos = []
for d in frappe.get_all(
"Job Card",
fields=["serial_no"],
filters={"docstatus": ("<", 2), "work_order": wo_doc.name, "operation_id": row.name},
):
used_serial_nos.extend(get_serial_nos(d.serial_no))
serial_nos = sorted(list(set(serial_nos) - set(used_serial_nos)))
row.serial_no = "\n".join(serial_nos[0 : cint(row.job_card_qty)])
def get_serial_nos_for_work_order(work_order, production_item):
serial_nos = []
for d in frappe.get_all(
"Serial No",
fields=["name"],
filters={
"work_order": work_order,
"item_code": production_item,
},
):
serial_nos.append(d.name)
return serial_nos
def validate_operation_data(row):
if flt(row.get("qty")) <= 0:
frappe.throw(
_("Quantity to Manufacture can not be zero for the operation {0}").format(
frappe.bold(row.get("operation"))
)
)
if flt(row.get("qty")) > flt(row.get("pending_qty")):
frappe.throw(
_("For operation {0}: Quantity ({1}) can not be greater than pending quantity ({2})").format(
frappe.bold(row.get("operation")),
frappe.bold(row.get("qty")),
frappe.bold(row.get("pending_qty")),
)
)
def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False):
doc = frappe.new_doc("Job Card")
doc.update(_job_card_values(work_order, row))
if work_order.track_semi_finished_goods or (
work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer
):
doc.get_required_items()
if work_order.track_semi_finished_goods:
doc.set_secondary_items()
if auto_create:
_auto_create_job_card(doc, row, enable_capacity_planning)
if enable_capacity_planning:
# automatically added scheduling rows shouldn't change status to WIP
doc.db_set("status", "Open")
return doc
def _job_card_values(work_order, row):
qty = row.job_card_qty or work_order.get("qty", 0)
values = _job_card_core_values(work_order, row, qty)
values.update(_job_card_warehouse_values(work_order, row, qty))
return values
def _job_card_core_values(work_order, row, qty):
return {
"work_order": work_order.name,
"workstation_type": row.get("workstation_type"),
"operation": row.get("operation"),
"workstation": row.get("workstation"),
"operation_row_id": cint(row.idx),
"posting_date": nowdate(),
"for_quantity": qty,
"operation_id": row.get("name"),
"bom_no": work_order.bom_no,
"project": work_order.project,
"company": work_order.company,
"sequence_id": row.get("sequence_id"),
"hour_rate": row.get("hour_rate"),
}
def _job_card_warehouse_values(work_order, row, qty):
if not work_order.skip_transfer or work_order.from_wip_warehouse:
wip_warehouse = work_order.wip_warehouse or row.get("wip_warehouse")
else:
wip_warehouse = work_order.source_warehouse or row.get("source_warehouse")
return {
"serial_no": row.get("serial_no"),
"time_required": (row.get("time_in_mins", 0) / work_order.qty) * qty,
"source_warehouse": row.get("source_warehouse") or work_order.get("source_warehouse"),
"target_warehouse": row.get("fg_warehouse") or work_order.get("fg_warehouse"),
"wip_warehouse": wip_warehouse,
"skip_material_transfer": row.get("skip_material_transfer"),
"backflush_from_wip_warehouse": row.get("backflush_from_wip_warehouse"),
"finished_good": row.get("finished_good"),
"semi_fg_bom": row.get("bom_no"),
"is_subcontracted": row.get("is_subcontracted"),
}
def _auto_create_job_card(doc, row, enable_capacity_planning):
doc.flags.ignore_mandatory = True
if enable_capacity_planning:
doc.schedule_time_logs(row)
doc.insert()
frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name)), alert=True)
def get_work_order_operation_data(work_order, operation, workstation):
for d in work_order.operations:
if d.operation == operation and d.workstation == workstation:
return d
@frappe.whitelist()
def create_pick_list(source_name: str, target_doc: str | dict | None = None, for_qty: float | None = None):
frappe.has_permission("Pick List", "create", throw=True)
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
postprocess = partial(
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
)
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
doc.purpose = "Material Transfer for Manufacture"
doc.for_qty = for_qty
doc.set_item_locations()
return doc
def _pick_list_mapping(postprocess):
return {
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": postprocess,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},
}
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
qty = 0
if desire_to_transfer <= pending_to_issue:
qty = desire_to_transfer
elif pending_to_issue > 0:
qty = pending_to_issue
if not qty:
target.delete()
return
target.qty = qty
target.stock_qty = qty
target.uom = frappe.get_value("Item", source.item_code, "stock_uom")
target.stock_uom = target.uom
target.conversion_factor = 1
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | None = None):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
return {
"Work Order": {
"doctype": "Material Request",
"validation": {"docstatus": ["=", 1]},
"field_map": {"name": "work_order"},
},
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("required_qty", "qty"),
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},
}
def _set_material_request_item(source, target, source_parent):
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.schedule_date = nowdate()
@frappe.whitelist()
def make_stock_return_entry(work_order: str):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
ManufactureStockEntry,
)
frappe.has_permission("Stock Entry", "create", throw=True)
wo_doc = frappe.get_cached_doc("Work Order", work_order)
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.from_bom = 1
stock_entry.is_return = 1
stock_entry.work_order = work_order
stock_entry.purpose = "Material Transfer for Manufacture"
stock_entry.bom_no = wo_doc.bom_no
stock_entry.set_stock_entry_type()
ste_cls = ManufactureStockEntry(stock_entry)
ste_cls.add_raw_materials_based_on_transfer()
ste_cls.return_available_materials_in_source_wh()
return stock_entry