Merge pull request #55596 from rohitwaghchaure/refactor-manufacturing-related-files

refactor: split manufacturing related files into mapper + services modules
This commit is contained in:
rohitwaghchaure
2026-06-04 00:40:35 +05:30
committed by GitHub
25 changed files with 6067 additions and 4888 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Document-mapping and query helpers for BOM (extracted from bom.py)."""
from functools import partial
import frappe
from frappe import _
from frappe.core.doctype.version.version import get_diff
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Field
from frappe.query_builder.functions import IfNull
from frappe.utils import today
from erpnext.stock.doctype.item.item import get_item_details
_BOM_DIFF_IDENTIFIERS = {
"operations": "operation",
"items": "item_code",
"secondary_items": "item_code",
"exploded_items": "item_code",
}
_VARIANT_BOM_MAPPING = {
"BOM": {"doctype": "BOM", "validation": {"docstatus": ["=", 1]}},
"BOM Item": {
"doctype": "BOM Item",
# stop get_mapped_doc copying parent bom_no to children
"field_no_map": ["bom_no"],
"condition": lambda doc: doc.has_variants == 0,
},
}
@frappe.whitelist()
def get_children(parent: str | None = None, is_root: bool = False, **filters):
frappe.has_permission("BOM", "read", throw=True)
if not parent or parent == "BOM":
frappe.msgprint(_("Please select a BOM"))
return
frappe.form_dict.parent = parent
bom_doc = frappe.get_cached_doc("BOM", parent)
frappe.has_permission("BOM", doc=bom_doc, throw=True)
bom_items = _bom_child_items(parent)
_enrich_bom_items(bom_items, bom_doc)
return bom_items
def _bom_child_items(parent):
return frappe.get_all(
"BOM Item",
fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"],
filters=[["parent", "=", parent]],
order_by="idx",
)
def _enrich_bom_items(bom_items, bom_doc):
item_names = tuple(d.get("item_code") for d in bom_items)
items = frappe.get_list(
"Item",
fields=["image", "description", "name", "stock_uom", "item_name", "is_sub_contracted_item"],
filters=[["name", "in", item_names]],
)
for bom_item in bom_items:
bom_item.update(next(item for item in items if item.get("name") == bom_item.get("item_code")))
bom_item.parent_bom_qty = bom_doc.quantity
bom_item.expandable = 0 if bom_item.value in ("", None) else 1
bom_item.image = frappe.db.escape(bom_item.image)
@frappe.whitelist()
def get_bom_diff(bom1: str, bom2: str):
frappe.has_permission("BOM", "read", throw=True)
if bom1 == bom2:
frappe.throw(
_("BOM 1 {0} and BOM 2 {1} should not be same").format(frappe.bold(bom1), frappe.bold(bom2))
)
doc1 = frappe.get_doc("BOM", bom1)
doc2 = frappe.get_doc("BOM", bom2)
out = get_diff(doc1, doc2)
out.row_changed, out.added, out.removed = [], [], []
for df in doc1.meta.fields:
_diff_table_field(df, doc1, doc2, out)
return out
def _diff_table_field(df, doc1, doc2, out):
from frappe.model import table_fields
if df.fieldtype not in table_fields:
return
identifier = _BOM_DIFF_IDENTIFIERS[df.fieldname]
old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname)
old_map = {d.get(identifier): d for d in old_value}
new_map = {d.get(identifier): d for d in new_value}
_collect_row_changes(df, identifier, old_map, new_value, out)
for d in old_value:
if d.get(identifier) not in new_map:
out.removed.append([df.fieldname, d.as_dict()])
def _collect_row_changes(df, identifier, old_map, new_value, out):
for i, d in enumerate(new_value):
if d.get(identifier) not in old_map:
out.added.append([df.fieldname, d.as_dict()])
continue
diff = get_diff(old_map[d.get(identifier)], d, for_child=True)
if diff and diff.changed:
out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed))
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
frappe.has_permission("Item", "read", throw=True)
searchfields = frappe.get_meta("Item", cached=True).get_search_fields()
fields = ["name", "item_name", "item_group", "description"]
fields.extend(f for f in searchfields if f not in ["name", "item_group", "description"])
query_filters = _item_query_filters(filters)
or_filters = _item_query_or_filters(txt, searchfields or ["name"], query_filters)
return frappe.get_list(
"Item",
fields=fields,
filters=query_filters,
or_filters=or_filters,
order_by="idx desc, name, item_name",
limit_start=start,
limit_page_length=page_len,
as_list=1,
)
def _item_query_filters(filters):
query_filters = [["disabled", "=", 0], [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()]]
if filters and filters.get("item_code"):
if not frappe.get_cached_value("Item", filters.get("item_code"), "has_variants"):
query_filters.append(["has_variants", "=", 0])
for fieldname, value in (filters or {}).items():
query_filters.append([fieldname, "=", value])
return query_filters
def _item_query_or_filters(txt, searchfields, query_filters):
if not txt:
return {}
or_filters = {s_field: ("like", f"%{txt}%") for s_field in searchfields}
barcodes = frappe.get_all(
"Item Barcode",
fields=["parent as item_code"],
filters={"barcode": ("like", f"%{txt}%")},
distinct=True,
)
barcode_codes = [d.item_code for d in barcodes]
if barcode_codes:
or_filters["name"] = ("in", barcode_codes)
return or_filters
@frappe.whitelist()
def make_variant_bom(
source_name: str,
bom_no: str,
item: str,
variant_items: str | list,
target_doc: Document | str | None = None,
):
frappe.has_permission("BOM", "write", throw=True)
postprocess = partial(
_postprocess_variant_bom, item=item, variant_items=variant_items, source_name=source_name
)
return get_mapped_doc("BOM", source_name, _VARIANT_BOM_MAPPING, target_doc, postprocess)
def _postprocess_variant_bom(source, doc, item, variant_items, source_name):
from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item
item_data = get_item_details(item)
doc.item = item
doc.quantity = 1
doc.update(
{
"item_name": item_data.item_name,
"description": item_data.description,
"uom": item_data.stock_uom,
"allow_alternative_item": item_data.allow_alternative_item,
}
)
add_variant_item(variant_items, doc, source_name)

View File

@@ -0,0 +1,313 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""BOM cost computation (extracted from bom.py).
``BOMCostingService`` wraps a BOM document (composition); bom.py keeps thin
delegating stubs so external callers (bom_update_log, etc.) keep working.
"""
import frappe
from frappe import _
from frappe.utils import flt
class BOMCostingService:
def __init__(self, doc):
self.doc = doc
def validate_bom_currency(self, item):
if (
item.get("bom_no")
and frappe.db.get_value("BOM", item.get("bom_no"), "currency") != self.doc.currency
):
frappe.throw(
_("Row {0}: Currency of the BOM #{1} should be equal to the selected currency {2}").format(
item.idx, item.bom_no, self.doc.currency
)
)
def get_rm_rate(self, arg, notify=True):
"""Get raw material rate as per selected method, if bom exists takes bom cost"""
if not self.doc.rm_cost_as_per:
self.doc.rm_cost_as_per = "Valuation Rate"
rate = self._raw_material_rate(arg, notify) if arg else 0
return flt(rate) * flt(self.doc.plc_conversion_rate or 1) / (self.doc.conversion_rate or 1)
def _raw_material_rate(self, arg, notify):
from erpnext.manufacturing.doctype.bom.bom import get_bom_item_rate
# Customer Provided parts and Supplier sourced parts will have zero rate
if frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") or arg.get(
"sourced_by_supplier"
):
return 0
if arg.get("bom_no") and (
self.doc.set_rate_of_sub_assembly_item_based_on_bom or arg.get("is_phantom_item")
):
return flt(self.get_bom_unitcost(arg["bom_no"])) * (arg.get("conversion_factor") or 1)
rate = get_bom_item_rate(arg, self.doc)
if not rate:
self._warn_rate_not_found(arg, notify)
return rate
def _warn_rate_not_found(self, arg, notify):
if self.doc.rm_cost_as_per == "Price List":
msg = _("Price not found for item {0} in price list {1}").format(
arg["item_code"], self.doc.buying_price_list
)
elif notify:
msg = _("{0} not found for item {1}").format(self.doc.rm_cost_as_per, arg["item_code"])
else:
return
frappe.msgprint(msg, alert=True)
def update_cost(
self,
update_parent: bool = True,
from_child_bom: bool = False,
update_hour_rate: bool = True,
save: bool = True,
):
if self.doc.docstatus == 2:
return
self.doc.flags.cost_updated = False
existing_bom_cost = self.doc.total_cost
if self.doc.docstatus == 1:
self.doc.flags.ignore_validate_update_after_submit = True
self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
if save:
self.doc.db_update()
if self.doc.total_cost != existing_bom_cost and update_parent:
self._update_parent_boms()
if not from_child_bom:
msg = "Cost Updated" if self.doc.flags.cost_updated else "No changes in cost found"
frappe.msgprint(_(msg), alert=True)
def _update_parent_boms(self):
bom_item = frappe.qb.DocType("BOM Item")
parent_boms = (
frappe.qb.from_(bom_item)
.select(bom_item.parent)
.distinct()
.where(
(bom_item.bom_no == self.doc.name)
& (bom_item.docstatus == 1)
& (bom_item.parenttype == "BOM")
)
).run(pluck=True)
for bom in parent_boms:
frappe.get_doc("BOM", bom).update_cost(from_child_bom=True)
def update_parent_cost(self):
if self.doc.total_cost:
cost = self.doc.total_cost / self.doc.quantity
bom_item = frappe.qb.DocType("BOM Item")
(
frappe.qb.update(bom_item)
.set(bom_item.rate, cost)
.set(bom_item.amount, bom_item.stock_qty * cost)
.where(
(bom_item.bom_no == self.doc.name)
& (bom_item.docstatus < 2)
& (bom_item.parenttype == "BOM")
)
).run()
def get_bom_unitcost(self, bom_no):
bom_table = frappe.qb.DocType("BOM")
bom = (
frappe.qb.from_(bom_table)
.select(
bom_table.name,
(bom_table.base_total_cost / bom_table.quantity).as_("unit_cost"),
)
.where((bom_table.is_active == 1) & (bom_table.name == bom_no))
).run(as_dict=1)
return bom and bom[0]["unit_cost"] or 0
def calculate_cost(self, save_updates=False, update_hour_rate=False):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates)
self.calculate_secondary_items_costs(save=save_updates)
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
old_cost = self.doc.total_cost
self.doc.total_cost = (
self.doc.operating_cost + self.doc.raw_material_cost - self.doc.secondary_items_cost
)
self.doc.base_total_cost = (
self.doc.base_operating_cost
+ self.doc.base_raw_material_cost
- self.doc.base_secondary_items_cost
)
if self.doc.total_cost != old_cost:
self.doc.flags.cost_updated = True
def calculate_op_cost(self, update_hour_rate=False):
"""Update workstation rate and calculates totals"""
self.doc.operating_cost = 0
self.doc.base_operating_cost = 0
if self.doc.get("with_operations"):
for d in self.doc.get("operations"):
self._accumulate_operation_cost(d, update_hour_rate)
elif self.doc.get("fg_based_operating_cost"):
self._set_fg_based_operating_cost()
def _accumulate_operation_cost(self, d, update_hour_rate):
if d.workstation or d.workstation_type:
self.update_rate_and_time(d, update_hour_rate)
operating_cost = d.operating_cost
base_operating_cost = d.base_operating_cost
if d.set_cost_based_on_bom_qty:
operating_cost = flt(d.cost_per_unit) * flt(self.doc.quantity)
base_operating_cost = flt(d.base_cost_per_unit) * flt(self.doc.quantity)
self.doc.operating_cost += flt(operating_cost)
self.doc.base_operating_cost += flt(base_operating_cost)
def _set_fg_based_operating_cost(self):
total = flt(self.doc.get("quantity")) * flt(self.doc.get("operating_cost_per_bom_quantity"))
self.doc.operating_cost = total
self.doc.base_operating_cost = flt(total * self.doc.conversion_rate, 2)
def update_rate_and_time(self, row, update_hour_rate=False):
if not row.hour_rate or update_hour_rate:
self._set_row_hour_rate(row)
if row.hour_rate:
row.base_hour_rate = flt(row.hour_rate) * flt(self.doc.conversion_rate)
if row.time_in_mins:
self._set_row_operating_costs(row)
if update_hour_rate:
row.db_update()
def _set_row_hour_rate(self, row):
hour_rate = 0
if row.workstation:
hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate"))
elif row.workstation_type:
hour_rate = flt(frappe.get_cached_value("Workstation Type", row.workstation_type, "hour_rate"))
if hour_rate:
row.hour_rate = (
hour_rate / flt(self.doc.conversion_rate) if self.doc.conversion_rate else hour_rate
)
def _set_row_operating_costs(self, row):
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
row.base_operating_cost = flt(row.operating_cost) * flt(self.doc.conversion_rate)
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0
for d in self.doc.get("items"):
old_rate = d.rate
if not self.doc.bom_creator and (d.is_stock_item or d.is_phantom_item):
d.rate = self.get_rm_rate(self._rm_rate_args(d), notify=False)
self._set_item_amounts(d)
total_rm_cost += d.amount
base_total_rm_cost += d.base_amount
if save and (old_rate != d.rate):
d.db_update()
self.doc.raw_material_cost = total_rm_cost
self.doc.base_raw_material_cost = base_total_rm_cost
def _rm_rate_args(self, d):
return {
"company": self.doc.company,
"item_code": d.item_code,
"bom_no": d.bom_no,
"qty": d.qty,
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
"is_phantom_item": d.is_phantom_item,
}
def _set_item_amounts(self, d):
d.base_rate = flt(d.rate) * flt(self.doc.conversion_rate)
d.amount = flt(
flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty")), d.precision("amount")
)
d.base_amount = d.amount * flt(self.doc.conversion_rate)
d.qty_consumed_per_unit = flt(d.stock_qty, d.precision("stock_qty")) / flt(
self.doc.quantity, self.doc.precision("quantity")
)
def calculate_secondary_items_costs(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.doc.precision("raw_material_cost")
for d in self.doc.get("secondary_items"):
if not d.is_legacy:
d.cost = flt(self.doc.raw_material_cost * (d.cost_allocation_per / 100), precision)
d.base_cost = flt(d.cost * self.doc.conversion_rate, precision)
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
if save:
d.db_update()
self.doc.secondary_items_cost = total_sm_cost
self.doc.base_secondary_items_cost = base_total_sm_cost
def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
rm_rate_map = self.get_rm_rate_map()
for row in self.doc.get("exploded_items"):
old_rate = flt(row.rate)
row.rate = rm_rate_map.get(row.item_code)
row.amount = flt(row.stock_qty) * flt(row.rate)
if old_rate != row.rate:
# Only db_update if changed
row.db_update()
def get_rm_rate_map(self) -> dict[str, float]:
"Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
rm_rate_map = {}
for item in self.doc.get("items"):
if item.bom_no:
# Get Item-Rate from Subassembly BOM
explosion_items = frappe.get_all(
"BOM Explosion Item",
filters={"parent": item.bom_no},
fields=["item_code", "rate"],
order_by=None, # to avoid sort index creation at db level (granular change)
)
explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
rm_rate_map.update(explosion_item_rate)
else:
rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)
return rm_rate_map

View File

@@ -0,0 +1,127 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""BOM exploded-items (flat BOM) computation (extracted from bom.py)."""
from operator import itemgetter
import frappe
from frappe.query_builder.functions import IfNull
from frappe.utils import flt
class BOMExplodedItemsService:
def __init__(self, doc):
self.doc = doc
def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data"""
self.get_exploded_items()
self.add_exploded_items(save=save)
def get_exploded_items(self):
"""Get all raw materials including items from child bom"""
self.doc.cur_exploded_items = {}
for d in self.doc.get("items"):
if d.bom_no:
self.get_child_exploded_items(d.bom_no, d.stock_qty, d.operation)
elif d.item_code:
self.add_to_cur_exploded_items(self._exploded_item_row(d))
@staticmethod
def _exploded_item_row(d):
return frappe._dict(
{
"item_code": d.item_code,
"item_name": d.item_name,
"operation": d.operation,
"is_sub_assembly_item": d.is_sub_assembly_item,
"source_warehouse": d.source_warehouse,
"description": d.description,
"image": d.image,
"stock_uom": d.stock_uom,
"stock_qty": flt(d.stock_qty),
"rate": flt(d.base_rate) / (flt(d.conversion_factor) or 1.0),
"include_item_in_manufacturing": d.include_item_in_manufacturing,
"sourced_by_supplier": d.sourced_by_supplier,
}
)
def add_to_cur_exploded_items(self, args):
key = args.item_code
if args.operation:
key = (args.item_code, args.operation)
if self.doc.cur_exploded_items.get(key):
self.doc.cur_exploded_items[key]["stock_qty"] += args.stock_qty
else:
self.doc.cur_exploded_items[key] = args
def get_child_exploded_items(self, bom_no, stock_qty, operation=None):
"""Add all items from Flat BOM of child BOM"""
for d in self._fetch_child_flat_bom_items(bom_no):
self.add_to_cur_exploded_items(self._child_exploded_row(d, stock_qty, operation))
@staticmethod
def _fetch_child_flat_bom_items(bom_no):
# Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
bom_item = frappe.qb.DocType("BOM Explosion Item")
bom = frappe.qb.DocType("BOM")
qty_consumed_per_unit = (bom_item.stock_qty / IfNull(bom.quantity, 1)).as_("qty_consumed_per_unit")
return (
frappe.qb.from_(bom_item)
.join(bom)
.on(bom_item.parent == bom.name)
.select(
bom_item.item_code,
bom_item.item_name,
bom_item.description,
bom_item.source_warehouse,
bom_item.operation,
bom_item.is_sub_assembly_item,
bom_item.stock_uom,
bom_item.stock_qty,
bom_item.rate,
bom_item.include_item_in_manufacturing,
bom_item.sourced_by_supplier,
qty_consumed_per_unit,
)
.where((bom.name == bom_no) & (bom.docstatus == 1))
).run(as_dict=1)
@staticmethod
def _child_exploded_row(d, stock_qty, operation):
return frappe._dict(
{
"item_code": d["item_code"],
"item_name": d["item_name"],
"source_warehouse": d["source_warehouse"],
"operation": d["operation"] or operation,
"description": d["description"],
"stock_uom": d["stock_uom"],
"stock_qty": d["qty_consumed_per_unit"] * stock_qty,
"rate": flt(d["rate"]),
"include_item_in_manufacturing": d.get("include_item_in_manufacturing", 0),
"sourced_by_supplier": d.get("sourced_by_supplier", 0),
"is_sub_assembly_item": d.get("is_sub_assembly_item", 0),
}
)
def add_exploded_items(self, save=True):
"Add items to Flat BOM table"
self.doc.set("exploded_items", [])
if save:
explosion_item = frappe.qb.DocType("BOM Explosion Item")
frappe.qb.from_(explosion_item).delete().where(explosion_item.parent == self.doc.name).run()
for d in sorted(self.doc.cur_exploded_items, key=itemgetter(0)):
ch = self.doc.append("exploded_items", {})
for i in self.doc.cur_exploded_items[d].keys():
ch.set(i, self.doc.cur_exploded_items[d][i])
ch.amount = flt(ch.stock_qty) * flt(ch.rate)
ch.qty_consumed_per_unit = flt(ch.stock_qty) / flt(self.doc.quantity)
ch.docstatus = self.doc.docstatus
if save:
ch.db_insert()

View File

@@ -0,0 +1,305 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Operating-cost helpers applied to Stock Entry / Job Card from a BOM.
Extracted from bom.py; bom.py re-exports them for backward compatibility.
"""
import frappe
from frappe import _
from frappe.query_builder import Field
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt
def add_additional_cost(stock_entry, work_order, job_card=None):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
expense_account = frappe.get_value(
"Company",
work_order.company,
"default_operating_cost_account",
)
add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=job_card)
add_operations_cost(stock_entry, work_order, expense_account, job_card=job_card)
def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=None):
bom = frappe.get_doc("BOM", work_order.bom_no)
item_amounts = _non_phantom_item_amounts(bom, _bom_items_table(work_order, job_card))
cost = _non_stock_items_cost(item_amounts, stock_entry, bom)
if cost:
stock_entry.append(
"additional_costs",
{"expense_account": expense_account, "description": _("Non stock items"), "amount": cost},
)
def _bom_items_table(work_order, job_card):
if work_order and not job_card:
return "exploded_items" if work_order.get("use_multi_level_bom") else "items"
return "items"
def _non_phantom_item_amounts(bom, table):
items = frappe._dict()
for d in bom.get(table):
# Phantom item is exploded, so its cost is considered via its components
if d.get("is_phantom_item"):
continue
items.setdefault(d.item_code, 0)
items[d.item_code] += flt(d.amount)
return items
def _non_stock_items_cost(item_amounts, stock_entry, bom):
non_stock_items = frappe.get_all(
"Item",
fields="name",
filters=[["name", "in", list(item_amounts.keys())], [IfNull(Field("is_stock_item"), 0), "=", 0]],
as_list=1,
)
cost = 0.0
for name in non_stock_items:
cost += flt(item_amounts.get(name[0])) * flt(stock_entry.fg_completed_qty) / flt(bom.quantity)
return cost
def add_operating_cost_component_wise(stock_entry, work_order=None, op_expense_account=None, job_card=None):
if not work_order:
return False
cost_added = False
for row in work_order.operations:
if job_card and job_card.operation_id != row.name:
continue
if not row.actual_operation_time:
continue
if _add_operation_workstation_costs(stock_entry, work_order, row, op_expense_account):
cost_added = True
return cost_added
def _add_operation_workstation_costs(stock_entry, work_order, row, op_expense_account):
from erpnext.stock.doctype.stock_entry.stock_entry import get_consumed_operating_cost
workstation_cost = frappe.get_all(
"Workstation Cost",
fields=["operating_component", "operating_cost"],
filters={"parent": row.workstation, "parenttype": "Workstation"},
)
consumed = get_consumed_operating_cost(work_order.name, stock_entry.bom_no, row.name) or []
cost_added = False
for wc in workstation_cost:
if _append_workstation_cost(stock_entry, row, wc, consumed, op_expense_account):
cost_added = True
return cost_added
def _append_workstation_cost(stock_entry, row, wc, consumed, op_expense_account):
expense_account = get_component_account(wc.operating_component, stock_entry.company) or op_expense_account
consumed_op_cost = next(
(c for c in consumed if c.get("operating_component") == wc.operating_component), {}
)
actual = _actual_operating_cost(wc, row, consumed_op_cost)
if not actual:
return False
remaining_qty = row.completed_qty - consumed_op_cost.get("consumed_qty", 0)
operating_cost = (actual / (remaining_qty or 1)) * stock_entry.fg_completed_qty
qty = min(remaining_qty, stock_entry.fg_completed_qty)
row_data = _workstation_cost_row(expense_account, row, wc, actual, operating_cost, qty)
stock_entry.append("additional_costs", row_data)
return True
def _actual_operating_cost(wc, row, consumed_op_cost):
return flt(
flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0)
- flt(consumed_op_cost.get("consumed_cost")),
row.precision("actual_operating_cost"),
)
def _workstation_cost_row(expense_account, row, wc, actual, operating_cost, qty):
precision = frappe.get_precision("Landed Cost Taxes and Charges", "amount")
return {
"expense_account": expense_account,
"description": _("{0} Operating Cost for operation {1}").format(
wc.operating_component, row.operation
),
"amount": flt(min(operating_cost, actual), precision),
"has_operating_cost": 1,
"operation_id": row.name,
"operating_component": wc.operating_component,
"qty": qty,
}
@frappe.request_cache
def get_component_account(parent, company):
return frappe.db.get_value(
"Workstation Operating Component Account", {"parent": parent, "company": company}, "expense_account"
)
def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None):
from erpnext.stock.doctype.stock_entry.stock_entry import get_remaining_operating_cost
remaining_operating_cost = get_remaining_operating_cost(work_order, stock_entry.bom_no)
if remaining_operating_cost:
_add_remaining_operating_cost(
stock_entry, work_order, expense_account, job_card, remaining_operating_cost
)
_add_additional_operating_cost(stock_entry, work_order, expense_account)
_add_corrective_operation_cost(stock_entry, work_order, expense_account)
def _add_remaining_operating_cost(stock_entry, work_order, expense_account, job_card, remaining_cost):
if add_operating_cost_component_wise(stock_entry, work_order, expense_account, job_card=job_card):
return
if job_card:
return
precision = frappe.get_precision("Landed Cost Taxes and Charges", "amount")
stock_entry.append(
"additional_costs",
{
"expense_account": expense_account,
"description": _("Operating Cost as per Work Order / BOM"),
"amount": flt(remaining_cost * stock_entry.fg_completed_qty, precision),
"has_operating_cost": 1,
},
)
def _add_additional_operating_cost(stock_entry, work_order, expense_account):
if not (work_order and work_order.additional_operating_cost and work_order.qty):
return
per_unit = flt(work_order.additional_operating_cost) / flt(work_order.qty)
if not per_unit:
return
stock_entry.append(
"additional_costs",
{
"expense_account": expense_account,
"description": "Additional Operating Cost",
"amount": per_unit * flt(stock_entry.fg_completed_qty),
},
)
def _add_corrective_operation_cost(stock_entry, work_order, expense_account):
if not (work_order and work_order.corrective_operation_cost and _corrective_cost_enabled()):
return
max_qty = _max_operation_quantity(work_order) - work_order.produced_qty
remaining = work_order.corrective_operation_cost - _utilised_corrective_cost(work_order)
stock_entry.append(
"additional_costs",
{
"expense_account": expense_account,
"description": "Corrective Operation Cost",
"has_corrective_cost": 1,
"amount": remaining / max_qty * flt(stock_entry.fg_completed_qty),
},
)
def _max_operation_quantity(work_order):
table = frappe.qb.DocType("Job Card")
query = (
frappe.qb.from_(table)
.select(Sum(table.total_completed_qty).as_("qty"))
.where(
(table.docstatus == 1)
& (table.work_order == work_order.name)
& (table.is_corrective_job_card == 0)
)
.groupby(table.operation)
)
return min([d.qty for d in query.run(as_dict=True)], default=0)
def _corrective_cost_enabled():
return cint(
frappe.db.get_single_value(
"Manufacturing Settings", "add_corrective_operation_cost_in_finished_good_valuation"
)
)
def _utilised_corrective_cost(work_order):
charges = frappe.qb.DocType("Landed Cost Taxes and Charges")
query = (
frappe.qb.from_(charges)
.select(Sum(charges.amount).as_("amount"))
.where(
charges.parent.isin(_manufacture_stock_entries(work_order)) & (charges.has_corrective_cost == 1)
)
)
return query.run(as_dict=True)[0].amount or 0
def _manufacture_stock_entries(work_order):
stock_entry = frappe.qb.DocType("Stock Entry")
return (
frappe.qb.from_(stock_entry)
.select(stock_entry.name)
.where(
(stock_entry.docstatus == 1)
& (stock_entry.work_order == work_order.name)
& (stock_entry.purpose == "Manufacture")
)
)
def get_op_cost_from_sub_assemblies(bom_no, op_cost=0):
# Get operating cost from sub-assemblies
bom_items = frappe.get_all(
"BOM Item", filters={"parent": bom_no, "docstatus": 1}, fields=["bom_no"], order_by="idx asc"
)
for row in bom_items:
if not row.bom_no:
continue
if cost := frappe.get_cached_value("BOM", row.bom_no, "operating_cost_per_bom_quantity"):
op_cost += flt(cost)
get_op_cost_from_sub_assemblies(row.bom_no, op_cost)
return op_cost
def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
if not secondary_items:
secondary_items = {}
for row in _child_bom_items_with_qty(bom_no):
if not row.bom_no:
continue
qty = flt(row.qty) * flt(qty)
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
secondary_items.update(items)
get_secondary_items_from_sub_assemblies(row.bom_no, company, qty, secondary_items)
return secondary_items
def _child_bom_items_with_qty(bom_no):
return frappe.get_all(
"BOM Item", filters={"parent": bom_no, "docstatus": 1}, fields=["bom_no", "qty"], order_by="idx asc"
)

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Query/data helpers for Production Plan (extracted from production_plan.py)."""
import frappe
@frappe.whitelist()
def get_so_details(sales_order: str):
frappe.has_permission("Sales Order", "read", throw=True)
return frappe.db.get_value(
"Sales Order", sales_order, ["transaction_date", "customer", "grand_total"], as_dict=1
)
@frappe.whitelist()
def sales_order_query(
doctype: str | None = None,
txt: str | None = None,
searchfield: str | None = None,
start: int | None = None,
page_len: int | None = None,
filters: dict | None = None,
):
frappe.has_permission("Production Plan", throw=True)
filters = filters or {}
so_table = frappe.qb.DocType("Sales Order")
table = frappe.qb.DocType("Sales Order Item")
query = (
frappe.qb.from_(so_table)
.join(table)
.on(table.parent == so_table.name)
.select(table.parent)
.distinct()
.where((table.qty > table.production_plan_qty) & (table.docstatus == 1))
)
query = _apply_sales_order_filters(query, so_table, table, filters, txt)
query = _paginate(query, start, page_len)
return query.run()
def _paginate(query, start, page_len):
if page_len:
query = query.limit(page_len)
if start:
query = query.offset(start)
return query
def _apply_sales_order_filters(query, so_table, table, filters, txt):
if filters.get("company"):
query = query.where(so_table.company == filters.get("company"))
if filters.get("sales_orders"):
query = query.where(so_table.name.isin(filters.get("sales_orders")))
if filters.get("item_code"):
query = query.where(table.item_code == filters.get("item_code"))
if txt:
query = query.where(table.parent.like(f"%{txt}%"))
return query

View File

@@ -0,0 +1,181 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""BOM explosion helpers for Production Plan material planning."""
import frappe
from frappe.query_builder.functions import IfNull, Sum
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import get_uom_conversion_factor
def get_exploded_items(item_details, company, bom_no, include_non_stock_items, planned_qty=1, doc=None):
data = _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
_apply_exploded_conversion(item_details, data)
return item_details
def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty):
bei = frappe.qb.DocType("BOM Explosion Item")
bom = frappe.qb.DocType("BOM")
item = frappe.qb.DocType("Item")
item_default = frappe.qb.DocType("Item Default")
item_uom = frappe.qb.DocType("UOM Conversion Detail")
return (
frappe.qb.from_(bei)
.join(bom)
.on(bom.name == bei.parent)
.join(item)
.on(item.name == bei.item_code)
.left_join(item_default)
.on((item_default.parent == item.name) & (item_default.company == company))
.left_join(item_uom)
.on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
.select(*_exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty))
.where(_exploded_item_filter(bei, bom, item, bom_no, include_non_stock_items))
.groupby(bei.item_code, bei.stock_uom)
).run(as_dict=True)
def _exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty):
return [
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
item.item_name,
item.name.as_("item_code"),
bei.description,
bei.stock_uom,
item.min_order_qty,
bei.source_warehouse,
item.default_material_request_type,
item.min_order_qty,
item_default.default_warehouse,
item.purchase_uom,
item_uom.conversion_factor,
item.safety_stock,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
]
def _exploded_item_filter(bei, bom, item, bom_no, include_non_stock_items):
stock_filter = item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1
return (bei.docstatus < 2) & (bei.is_sub_assembly_item == 0) & (bom.name == bom_no) & stock_filter
def _apply_exploded_conversion(item_details, data):
for d in data:
if not d.conversion_factor and d.purchase_uom:
d.conversion_factor = get_uom_conversion_factor(d.item_code, d.purchase_uom)
item_details.setdefault(d.get("item_code"), d)
def get_subitems(
doc,
data,
item_details,
bom_no,
company,
include_non_stock_items,
include_subcontracted_items,
parent_qty,
planned_qty=1,
):
for d in _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planned_qty):
_process_subitem(
doc, data, item_details, d, company, include_non_stock_items, include_subcontracted_items
)
return {key: value for key, value in item_details.items() if not value.get("is_phantom_item")}
def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planned_qty):
bom_item = frappe.qb.DocType("BOM Item")
bom = frappe.qb.DocType("BOM")
item = frappe.qb.DocType("Item")
item_default = frappe.qb.DocType("Item Default")
item_uom = frappe.qb.DocType("UOM Conversion Detail")
return (
frappe.qb.from_(bom_item)
.join(bom)
.on(bom.name == bom_item.parent)
.join(item)
.on(bom_item.item_code == item.name)
.left_join(item_default)
.on((item.name == item_default.parent) & (item_default.company == company))
.left_join(item_uom)
.on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
.select(*_subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty))
.where(_subitem_filter(bom_item, bom, item, bom_no, include_non_stock_items))
.groupby(bom_item.item_code)
.orderby(bom_item.idx)
).run(as_dict=True)
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
return [
bom_item.item_code,
item.default_material_request_type,
item.item_name,
qty,
item.is_sub_contracted_item.as_("is_sub_contracted"),
bom_item.source_warehouse,
item.default_bom.as_("default_bom"),
bom_item.description.as_("description"),
bom_item.stock_uom.as_("stock_uom"),
item.min_order_qty.as_("min_order_qty"),
item.safety_stock.as_("safety_stock"),
item_default.default_warehouse,
item.purchase_uom,
item_uom.conversion_factor,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
bom_item.is_phantom_item,
]
def _subitem_filter(bom_item, bom, item, bom_no, include_non_stock_items):
stock_filter = item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1
return (
(bom.name == bom_no)
& (bom_item.is_sub_assembly_item == 0)
& (bom_item.docstatus < 2)
& (stock_filter | (bom_item.is_phantom_item == 1))
)
def _process_subitem(
doc, data, item_details, d, company, include_non_stock_items, include_subcontracted_items
):
if not data.get("include_exploded_items") or not d.default_bom:
_merge_subitem(item_details, d)
if d.is_phantom_item or (data.get("include_exploded_items") and d.default_bom):
if _should_explode_subitem(d, include_subcontracted_items) and d.qty > 0:
get_subitems(
doc,
data,
item_details,
d.default_bom,
company,
include_non_stock_items,
include_subcontracted_items,
d.qty,
)
def _merge_subitem(item_details, d):
if d.item_code in item_details:
item_details[d.item_code].qty = item_details[d.item_code].qty + d.qty
return
if not d.conversion_factor and d.purchase_uom:
d.conversion_factor = get_uom_conversion_factor(d.item_code, d.purchase_uom)
item_details[d.item_code] = d
def _should_explode_subitem(d, include_subcontracted_items):
return bool(
(d.default_material_request_type in ["Manufacture", "Purchase"] and not d.is_sub_contracted)
or (d.is_sub_contracted and include_subcontracted_items)
or d.is_phantom_item
)

View File

@@ -0,0 +1,667 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Material Request planning and creation for a Production Plan.
Consolidates the former ``material_planning``, ``material_request_items`` and
``material_request_helpers`` modules. Also re-exports the planning helpers so
existing imports of ``...services.material_planning`` keep working through here.
"""
import copy
import json
from collections import defaultdict
import frappe
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.utils import add_days, ceil, cint, comma_and, flt, get_link_to_form, nowdate
from frappe.utils.csvutils import build_csv_response
from erpnext.manufacturing.doctype.production_plan.services.bom_explosion import (
get_exploded_items,
get_subitems,
)
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import (
get_bin_details,
get_item_data,
get_sales_orders,
get_uom_conversion_factor,
get_warehouse_list,
set_default_warehouses,
)
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_planning import (
get_raw_materials_of_sub_assembly_items,
get_sub_assembly_items,
)
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.get_item_details import get_conversion_factor
class MaterialRequestService:
def __init__(self, doc):
self.doc = doc
def validate_mr_subcontracted(self):
for row in self.doc.mr_items:
if row.material_request_type != "Subcontracting":
continue
if not frappe.db.get_value("Item", row.item_code, "is_sub_contracted_item"):
frappe.throw(
_("Item {0} is not a subcontracted item").format(row.item_code),
title=_("Invalid Item"),
)
def make_material_request(self):
"""Create Material Requests grouped by Sales Order and Material Request Type"""
self.validate_mr_subcontracted()
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
msgprint(_("All items are already requested"))
return
material_request_map = {}
material_request_list = []
for item in self.doc.mr_items:
if item.quantity == item.requested_qty:
continue
self._add_item_to_material_request(item, material_request_map, material_request_list)
self._submit_material_requests(material_request_list)
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
item_doc = frappe.get_cached_doc("Item", item.item_code)
material_request_type = item.material_request_type or item_doc.default_material_request_type
# key for Sales Order:Material Request Type:Customer
key = "{}:{}:{}".format(item.sales_order, material_request_type, "")
if key not in material_request_map:
material_request_map[key] = self._new_material_request(material_request_type)
material_request_list.append(material_request_map[key])
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
row = self._material_request_item(item, material_request_type, schedule_date)
material_request_map[key].append("items", row)
def _new_material_request(self, material_request_type):
mr = frappe.new_doc("Material Request")
mr.update(
{
"transaction_date": nowdate(),
"status": "Draft",
"company": self.doc.company,
"material_request_type": material_request_type,
}
)
return mr
def _material_request_item(self, item, material_request_type, schedule_date):
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
project = (
frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None
)
return {
"item_code": item.item_code,
"from_warehouse": from_warehouse,
"qty": item.quantity - item.requested_qty,
"uom": item.uom,
"schedule_date": schedule_date,
"warehouse": item.warehouse,
"sales_order": item.sales_order,
"production_plan": self.doc.name,
"material_request_plan_item": item.name,
"project": project,
}
def _submit_material_requests(self, material_request_list):
for material_request in material_request_list:
material_request.flags.ignore_permissions = 1
material_request.run_method("set_missing_values")
material_request.save()
if self.doc.get("submit_material_request"):
material_request.submit()
frappe.flags.mute_messages = False
if not material_request_list:
msgprint(_("No material request created"))
return
links = [get_link_to_form("Material Request", m.name) for m in material_request_list]
msgprint(_("{0} created").format(comma_and(links)))
@frappe.whitelist()
def get_items_for_material_requests(
doc: str | frappe._dict | Document,
warehouses: str | list | None = None,
get_parent_warehouse_data: bool | int | None = None,
):
frappe.has_permission("Production Plan", "read", throw=True)
doc = _normalize_mr_doc(doc)
warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data)
doc["mr_items"] = []
po_items = _collect_po_items(doc)
_validate_po_items(po_items)
ignore_ordered_qty = _effective_ignore_ordered_qty(doc, po_items)
so_item_details = _collect_item_details(doc, po_items)
mr_items = _build_mr_items(doc, so_item_details, ignore_ordered_qty)
mr_items = _apply_other_locations(
doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data
)
if not mr_items:
_warn_no_mr_items(doc)
return mr_items
def _normalize_mr_doc(doc):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
return doc
def _filter_warehouses(doc, warehouses, get_parent_warehouse_data):
if not warehouses:
return warehouses
warehouses = list(set(get_warehouse_list(warehouses)))
for_warehouse = doc.get("for_warehouse")
if for_warehouse and not get_parent_warehouse_data and for_warehouse in warehouses:
warehouses.remove(for_warehouse)
return warehouses
def _collect_po_items(doc):
po_items = doc.get("po_items") if doc.get("po_items") else doc.get("items")
for sa_row in doc.get("sub_assembly_items") or []:
sa_row = frappe._dict(sa_row)
if sa_row.type_of_manufacturing != "Material Request":
continue
po_items.append(
frappe._dict(
{
"item_code": sa_row.production_item,
"required_qty": sa_row.qty,
"include_exploded_items": 0,
}
)
)
return po_items
def _validate_po_items(po_items):
if not po_items or not [row.get("item_code") for row in po_items if row.get("item_code")]:
frappe.throw(
_("Items to Manufacture are required to pull the Raw Materials associated with it."),
title=_("Items Required"),
)
def _effective_ignore_ordered_qty(doc, po_items):
if doc.get("ignore_existing_ordered_qty"):
return doc.get("ignore_existing_ordered_qty")
return any(data.get("ignore_existing_ordered_qty") for data in po_items)
def _build_sub_assembly_map(doc):
if not (doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items")):
return {}
sub_assembly_items = defaultdict(int)
for d in doc.get("sub_assembly_items"):
key = (d.get("production_item"), d.get("bom_no"), d.get("type_of_manufacturing"))
sub_assembly_items[key] += d.get("qty")
return {k[:2]: v for k, v in sub_assembly_items.items()}
def _collect_item_details(doc, po_items):
company = doc.get("company")
sub_assembly_items = _build_sub_assembly_map(doc)
existing_sub_assembly_items = set()
so_item_details = frappe._dict()
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
for data in po_items:
if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
data["include_exploded_items"] = 1
item_details = _item_details_for_row(
doc, data, company, sub_assembly_items, existing_sub_assembly_items
)
_accumulate_so_items(so_item_details, data.get("sales_order"), item_details, qty_precision)
return so_item_details
def _item_details_for_row(doc, data, company, sub_assembly_items, existing_sub_assembly_items):
planned_qty = data.get("required_qty") or data.get("planned_qty")
if data.get("bom") or data.get("bom_no"):
return _bom_item_details(
doc, data, company, planned_qty, sub_assembly_items, existing_sub_assembly_items
)
if data.get("item_code"):
return _plain_item_details(doc, data, planned_qty)
return {}
def _bom_item_details(doc, data, company, planned_qty, sub_assembly_items, existing_sub_assembly_items):
bom_no, include_non_stock_items, include_subcontracted_items = _bom_explosion_flags(doc, data)
if not planned_qty:
frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx")))
if not bom_no:
return {}
return _explode_bom_items(
doc,
data,
company,
bom_no,
planned_qty,
include_non_stock_items,
include_subcontracted_items,
sub_assembly_items,
existing_sub_assembly_items,
)
def _bom_explosion_flags(doc, data):
if data.get("required_qty"):
include_subcontracted_items = 1 if data.get("include_exploded_items") else 0
return data.get("bom"), 1, include_subcontracted_items
return data.get("bom_no"), doc.get("include_non_stock_items"), doc.get("include_subcontracted_items")
def _explode_bom_items(
doc,
data,
company,
bom_no,
planned_qty,
include_non_stock_items,
include_subcontracted_items,
sub_assembly_items,
existing_sub_assembly_items,
):
item_details = {}
if (
data.get("include_exploded_items")
and doc.get("skip_available_sub_assembly_item")
and doc.get("sub_assembly_items")
):
return get_raw_materials_of_sub_assembly_items(
existing_sub_assembly_items,
item_details,
company,
bom_no,
include_non_stock_items,
sub_assembly_items,
planned_qty=planned_qty,
)
if data.get("include_exploded_items") and include_subcontracted_items:
return get_exploded_items(
item_details, company, bom_no, include_non_stock_items, planned_qty=planned_qty, doc=doc
)
return get_subitems(
doc,
data,
item_details,
bom_no,
company,
include_non_stock_items,
include_subcontracted_items,
1,
planned_qty=planned_qty,
)
def _plain_item_details(doc, data, planned_qty):
item_master = frappe.get_doc("Item", data["item_code"]).as_dict()
purchase_uom = item_master.purchase_uom or item_master.stock_uom
conversion_factor = (
get_uom_conversion_factor(item_master.name, purchase_uom) if item_master.purchase_uom else 1.0
)
return {
item_master.item_code: frappe._dict(
{
"item_name": item_master.item_name,
"default_bom": doc.bom,
"purchase_uom": purchase_uom,
"default_warehouse": item_master.default_warehouse,
"min_order_qty": item_master.min_order_qty,
"default_material_request_type": item_master.default_material_request_type,
"qty": planned_qty or 1,
"is_sub_contracted": item_master.is_sub_contracted_item,
"item_code": item_master.name,
"description": item_master.description,
"stock_uom": item_master.stock_uom,
"conversion_factor": conversion_factor,
"safety_stock": item_master.safety_stock,
}
)
}
def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precision):
for key, details in item_details.items():
details.qty = flt(details.qty, qty_precision)
so_item_details.setdefault(sales_order, frappe._dict())
if key in so_item_details[sales_order]:
existing = so_item_details[sales_order][key]
existing["qty"] = existing.get("qty", 0) + flt(details.qty)
else:
so_item_details[sales_order][key] = details
def _build_mr_items(doc, so_item_details, ignore_ordered_qty):
mr_items = []
consumed_qty = defaultdict(float)
warehouse = doc.get("for_warehouse")
company = doc.get("company")
include_safety_stock = doc.get("include_safety_stock")
for sales_order, item_dict in so_item_details.items():
for details in item_dict.values():
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
row = _mr_item_for_details(
doc,
details,
sales_order,
company,
ignore_ordered_qty,
include_safety_stock,
warehouse,
consumed_qty,
)
if row:
mr_items.append(row)
return mr_items
def _mr_item_for_details(
doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty
):
bin_dict = get_bin_details(details, doc.company, warehouse)
bin_dict = bin_dict[0] if bin_dict else {}
if details.qty <= 0:
return None
return get_material_request_items(
doc,
details,
sales_order,
company,
ignore_ordered_qty,
include_safety_stock,
warehouse,
bin_dict,
consumed_qty,
)
def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data):
if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses):
return mr_items
new_mr_items = []
for item in mr_items:
get_materials_from_other_locations(item, warehouses, new_mr_items, doc.get("company"))
return new_mr_items
def _warn_no_mr_items(doc):
to_enable = frappe.bold(frappe.get_meta("Production Plan").get_field("ignore_existing_ordered_qty").label)
warehouse = frappe.bold(doc.get("for_warehouse"))
message = (
_(
"As there are sufficient raw materials, Material Request is not required for Warehouse {0}."
).format(warehouse)
+ "<br><br>"
)
message += _("If you still want to proceed, please enable {0}.").format(to_enable)
frappe.msgprint(message, title=_("Note"))
def get_material_request_items(
doc,
row,
sales_order,
company,
ignore_existing_ordered_qty,
include_safety_stock,
warehouse,
bin_dict,
consumed_qty,
):
required_qty = _required_qty_for_mr(
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty
)
required_qty = _adjust_required_qty_for_uom(row, required_qty, include_safety_stock)
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
return _material_request_item_row(
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
)
def _required_qty_for_mr(doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty):
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = flt(row.get("qty"))
else:
key = (row.get("item_code"), warehouse)
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
if available_qty > 0:
required_qty = max(0, flt(row.get("qty")) - available_qty)
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
else:
required_qty = flt(row.get("qty"))
if doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
required_qty = row["min_order_qty"]
return required_qty
def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
if not row["purchase_uom"]:
row["purchase_uom"] = row["stock_uom"]
if row["purchase_uom"] != row["stock_uom"]:
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
frappe.throw(
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
row["purchase_uom"], row["stock_uom"], row.item_code
)
)
required_qty = required_qty / row["conversion_factor"]
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
if include_safety_stock:
required_qty += flt(row["safety_stock"])
return required_qty
def _mr_purchase_conversion_factor(row):
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
if (
row.get("default_material_request_type") == "Purchase"
and item_details.purchase_uom
and item_details.purchase_uom != item_details.stock_uom
):
return get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
return 1.0
def _material_request_item_row(
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
):
warehouse = (
warehouse
or row.get("source_warehouse")
or row.get("default_warehouse")
or item_group_defaults.get("default_warehouse")
)
return {
"item_code": row.item_code,
"item_name": row.item_name,
"quantity": required_qty / conversion_factor,
"conversion_factor": conversion_factor,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
"warehouse": warehouse,
"safety_stock": row.safety_stock,
"actual_qty": bin_dict.get("actual_qty", 0),
"projected_qty": bin_dict.get("projected_qty", 0),
"ordered_qty": bin_dict.get("ordered_qty", 0),
"reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0),
"min_order_qty": row["min_order_qty"],
"material_request_type": row.get("default_material_request_type"),
"sales_order": sales_order,
"description": row.get("description"),
"uom": row.get("purchase_uom") or row.get("stock_uom"),
"main_item_code": row.get("main_bom_item"),
"from_bom": row.get("main_bom"),
}
def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
locations = get_available_item_locations(
item.get("item_code"),
warehouses,
item.get("quantity") * item.get("conversion_factor"),
company,
ignore_validation=True,
)
required_qty = item.get("quantity")
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
# Convert qty to stock UOM
required_qty = required_qty * item.get("conversion_factor")
required_qty = _transfer_from_locations(item, locations, new_mr_items, required_qty)
_add_remaining_purchase_request(item, new_mr_items, required_qty)
def _transfer_from_locations(item, locations, new_mr_items, required_qty):
# get available material by transferring to production warehouse
for d in locations:
if required_qty <= 0:
return required_qty
new_dict = copy.deepcopy(item)
quantity = required_qty if d.get("qty") > required_qty else d.get("qty")
new_dict.update(
{
"quantity": quantity,
"material_request_type": "Material Transfer",
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
"from_warehouse": d.get("warehouse"),
"conversion_factor": 1.0,
}
)
required_qty -= quantity
new_mr_items.append(new_dict)
return required_qty
def _add_remaining_purchase_request(item, new_mr_items, required_qty):
# raise purchase request for remaining qty
precision = frappe.get_precision("Material Request Plan Item", "quantity")
if flt(required_qty, precision) <= 0:
return
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = required_qty / item.get("conversion_factor")
new_mr_items.append(item)
@frappe.whitelist()
def download_raw_materials(doc: str | dict | Document, warehouses: str | list | None = None):
frappe.has_permission("Production Plan", "read", throw=True)
doc = _normalize_mr_doc(doc)
item_list = [_raw_materials_header()]
doc.warehouse = None
frappe.flags.show_qty_in_stock_uom = 1
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
_build_download_rows(doc, items, item_list)
build_csv_response(item_list, doc.name)
def _raw_materials_header():
return [
"Item Code",
"Item Name",
"Description",
"Stock UOM",
"Warehouse",
"Required Qty as per BOM",
"Projected Qty",
"Available Qty In Hand",
"Ordered Qty",
"Planned Qty",
"Reserved Qty for Production",
"Safety Stock",
"Required Qty",
]
def _build_download_rows(doc, items, item_list):
duplicate_item_wh_list = frappe._dict()
for d in items:
key = (d.get("item_code"), d.get("warehouse"))
if key in duplicate_item_wh_list:
duplicate_item_wh_list[key][12] += d.get("quantity")
continue
rm_data = _raw_material_row(d)
duplicate_item_wh_list[key] = rm_data
item_list.append(rm_data)
if not doc.get("for_warehouse"):
_append_other_warehouse_bins(item_list, d, doc)
def _raw_material_row(d):
return [
d.get("item_code"),
d.get("item_name"),
d.get("description"),
d.get("stock_uom"),
d.get("warehouse"),
d.get("required_bom_qty"),
d.get("projected_qty"),
d.get("actual_qty"),
d.get("ordered_qty"),
d.get("planned_qty"),
d.get("reserved_qty_for_production"),
d.get("safety_stock"),
d.get("quantity"),
]
def _append_other_warehouse_bins(item_list, d, doc):
row = {"item_code": d.get("item_code")}
for bin_dict in get_bin_details(row, doc.company, all_warehouse=True):
if d.get("warehouse") == bin_dict.get("warehouse"):
continue
item_list.append(
[
"",
"",
"",
bin_dict.get("warehouse"),
"",
bin_dict.get("projected_qty", 0),
bin_dict.get("actual_qty", 0),
bin_dict.get("ordered_qty", 0),
bin_dict.get("reserved_qty_for_production", 0),
]
)

View File

@@ -0,0 +1,161 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Small query helpers shared by Production Plan material planning."""
import json
import frappe
from frappe.query_builder.functions import IfNull, Sum
from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
def get_uom_conversion_factor(item_code, uom):
return frappe.db.get_value(
"UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor"
)
@frappe.whitelist()
def get_bin_details(
row: str | dict, company: str, for_warehouse: str | None = None, all_warehouse: bool = False
):
frappe.has_permission("Production Plan", "read", throw=True)
if isinstance(row, str):
row = frappe._dict(json.loads(row))
bin = frappe.qb.DocType("Bin")
subquery = _bin_warehouse_subquery(bin, company, row, for_warehouse, all_warehouse)
query = (
frappe.qb.from_(bin)
.select(bin.warehouse, *_bin_qty_columns(bin))
.where((bin.item_code == row["item_code"]) & (bin.warehouse.isin(subquery)))
.groupby(bin.item_code, bin.warehouse)
)
return query.run(as_dict=True)
def _bin_warehouse_subquery(bin, company, row, for_warehouse, all_warehouse):
wh = frappe.qb.DocType("Warehouse")
subquery = frappe.qb.from_(wh).select(wh.name).where(wh.company == company)
warehouse = ""
if not all_warehouse:
warehouse = for_warehouse or row.get("source_warehouse") or row.get("default_warehouse")
if warehouse:
lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"])
subquery = subquery.where((wh.lft >= lft) & (wh.rgt <= rgt) & (wh.name == bin.warehouse))
return subquery
def _bin_qty_columns(bin):
return [
IfNull(Sum(bin.projected_qty), 0).as_("projected_qty"),
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
IfNull(Sum(bin.ordered_qty), 0).as_("ordered_qty"),
IfNull(Sum(bin.reserved_qty_for_production), 0).as_("reserved_qty_for_production"),
IfNull(Sum(bin.planned_qty), 0).as_("planned_qty"),
]
def get_warehouse_list(warehouses):
warehouse_list = []
if isinstance(warehouses, str):
warehouses = json.loads(warehouses)
for row in warehouses:
child_warehouses = frappe.db.get_descendants("Warehouse", row.get("warehouse"))
if child_warehouses:
warehouse_list.extend(child_warehouses)
else:
warehouse_list.append(row.get("warehouse"))
return warehouse_list
@frappe.whitelist()
def get_item_data(item_code: str):
frappe.has_permission("Item", "read", throw=True)
item_details = get_item_details(item_code)
return {
"bom_no": item_details.get("bom_no"),
"stock_uom": item_details.get("stock_uom"),
"description": item_details.get("description"),
}
def set_default_warehouses(row, default_warehouses):
for field in ["wip_warehouse", "fg_warehouse", "scrap_warehouse"]:
if not row.get(field):
row[field] = default_warehouses.get(field)
def get_sales_orders(self):
bom = frappe.qb.DocType("BOM")
so = frappe.qb.DocType("Sales Order")
so_item = frappe.qb.DocType("Sales Order Item")
bom_subquery = frappe.qb.from_(bom).select(bom.name).where(bom.is_active == 1)
query = _open_so_base_query(self, so, so_item)
query = _apply_open_so_filters(self, query, so, so_item)
if self.item_code and frappe.db.exists("Item", self.item_code):
query = query.where(so_item.item_code == self.item_code)
bom_subquery = bom_subquery.where(self.get_bom_item_condition() or bom.item == so_item.item_code)
packed_subquery = _packed_item_subquery(bom, so, so_item)
query = query.where(ExistsCriterion(bom_subquery) | ExistsCriterion(packed_subquery))
return query.run(as_dict=True)
def _open_so_base_query(plan, so, so_item):
return (
frappe.qb.from_(so)
.from_(so_item)
.select(so.name, so.transaction_date, so.customer, so.base_grand_total)
.distinct()
.where(
(so_item.parent == so.name)
& (so.docstatus == 1)
& (so.status.notin(["Stopped", "Closed"]))
& (so.company == plan.company)
& (so_item.qty > so_item.production_plan_qty)
)
)
def _apply_open_so_filters(plan, query, so, so_item):
date_field_mapper = {
"from_date": so.transaction_date >= plan.from_date,
"to_date": so.transaction_date <= plan.to_date,
"from_delivery_date": so_item.delivery_date >= plan.from_delivery_date,
"to_delivery_date": so_item.delivery_date <= plan.to_delivery_date,
}
for field, value in date_field_mapper.items():
if plan.get(field):
query = query.where(value)
for field in ("customer", "project", "sales_order_status"):
if plan.get(field):
so_field = "status" if field == "sales_order_status" else field
query = query.where(so[so_field] == plan.get(field))
return query
def _packed_item_subquery(bom, so, so_item):
pi = frappe.qb.DocType("Packed Item")
bom_exists = ExistsCriterion(
frappe.qb.from_(bom).select(bom.name).where((bom.item == pi.item_code) & (bom.is_active == 1))
)
return (
frappe.qb.from_(pi)
.select(pi.name)
.where((pi.parent == so.name) & (pi.parent_item == so_item.item_code) & bom_exists)
)

View File

@@ -0,0 +1,381 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Sales Order / Material Request sourcing into Production Plan items (extracted from production_plan.py)."""
import frappe
from frappe import _
from frappe.query_builder.functions import IfNull
from frappe.utils import flt, now_datetime
from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import get_sales_orders
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
class SalesOrderSourcingService:
def __init__(self, doc):
self.doc = doc
def get_open_sales_orders(self):
"""Pull sales orders which are pending to deliver based on criteria selected"""
open_so = get_sales_orders(self.doc)
if open_so:
self.add_so_in_table(open_so)
else:
frappe.msgprint(_("Sales orders are not available for production"))
def add_so_in_table(self, open_so):
"""Add sales orders in the table"""
self.doc.set("sales_orders", [])
for data in open_so:
self.doc.append(
"sales_orders",
{
"sales_order": data.name,
"sales_order_date": data.transaction_date,
"customer": data.customer,
"grand_total": data.base_grand_total,
},
)
def get_pending_material_requests(self):
"""Pull Material Requests that are pending based on criteria selected"""
mr = frappe.qb.DocType("Material Request")
mr_item = frappe.qb.DocType("Material Request Item")
query = self._pending_mr_base_query(mr, mr_item)
query = self._apply_pending_mr_filters(query, mr, mr_item)
self.add_mr_in_table(query.run(as_dict=True))
def _pending_mr_base_query(self, mr, mr_item):
bom = frappe.qb.DocType("BOM")
bom_exists = ExistsCriterion(
frappe.qb.from_(bom)
.select(bom.name)
.where((bom.item == mr_item.item_code) & (bom.is_active == 1))
)
return (
frappe.qb.from_(mr)
.from_(mr_item)
.select(mr.name, mr.transaction_date)
.distinct()
.where(
(mr_item.parent == mr.name)
& (mr.material_request_type == "Manufacture")
& (mr.docstatus == 1)
& (mr.status != "Stopped")
& (mr.company == self.doc.company)
& (mr_item.qty > IfNull(mr_item.ordered_qty, 0))
& bom_exists
)
)
def _apply_pending_mr_filters(self, query, mr, mr_item):
if self.doc.from_date:
query = query.where(mr.transaction_date >= self.doc.from_date)
if self.doc.to_date:
query = query.where(mr.transaction_date <= self.doc.to_date)
if self.doc.warehouse:
query = query.where(mr_item.warehouse == self.doc.warehouse)
if self.doc.item_code:
query = query.where(mr_item.item_code == self.doc.item_code)
return query
def add_mr_in_table(self, pending_mr):
"""Add Material Requests in the table"""
self.doc.set("material_requests", [])
for data in pending_mr:
self.doc.append(
"material_requests",
{"material_request": data.name, "material_request_date": data.transaction_date},
)
def combine_so_items(self):
if not (self.doc.combine_items and self.doc.po_items and len(self.doc.po_items) > 0):
self.get_items()
return
items = [self._combined_so_item(row) for row in self.doc.po_items]
self.doc.set("po_items", [])
self.add_items(items)
@staticmethod
def _combined_so_item(row):
return frappe._dict(
{
"parent": row.sales_order,
"item_code": row.item_code,
"warehouse": row.warehouse,
"qty": row.pending_qty,
"pending_qty": row.pending_qty,
"conversion_factor": 1.0,
"description": row.description,
"bom_no": row.bom_no,
}
)
def get_items(self):
self.doc.set("po_items", [])
if self.doc.get_items_from == "Sales Order":
self.get_so_items()
elif self.doc.get_items_from == "Material Request":
self.get_mr_items()
def get_so_mr_list(self, field, table):
"""Returns a list of Sales Orders or Material Requests from the respective tables"""
so_mr_list = [d.get(field) for d in self.doc.get(table) if d.get(field)]
return so_mr_list
def get_bom_item_condition(self):
"""Check if Item or if its Template has a BOM."""
bom_item_condition = None
has_bom = frappe.db.exists({"doctype": "BOM", "item": self.doc.item_code, "docstatus": 1})
if not has_bom:
bom = frappe.qb.DocType("BOM")
template_item = frappe.db.get_value("Item", self.doc.item_code, ["variant_of"])
bom_item_condition = bom.item == template_item or None
return bom_item_condition
def get_so_items(self):
# Check for empty table or empty rows
if not self.doc.get("sales_orders") or not self.get_so_mr_list("sales_order", "sales_orders"):
frappe.throw(_("Please fill the Sales Orders table"), title=_("Sales Orders Required"))
so_list = self.get_so_mr_list("sales_order", "sales_orders")
items = self._so_items(so_list)
packed_items = self._so_packed_items(so_list)
self.add_items(items + packed_items)
self.doc.calculate_total_planned_qty()
def _so_items(self, so_list):
bom = frappe.qb.DocType("BOM")
so_item = frappe.qb.DocType("Sales Order Item")
items_subquery = frappe.qb.from_(bom).select(bom.name).where(bom.is_active == 1)
items_query = (
frappe.qb.from_(so_item)
.select(*_so_item_columns(so_item))
.distinct()
.where(_so_items_filter(so_item, so_list))
)
if self.doc.item_code and frappe.db.exists("Item", self.doc.item_code):
items_query = items_query.where(so_item.item_code == self.doc.item_code)
items_subquery = items_subquery.where(
self.get_bom_item_condition() or bom.item == so_item.item_code
)
items = items_query.where(ExistsCriterion(items_subquery)).run(as_dict=True)
_set_so_item_pending_qty(items)
return items
def _so_packed_items(self, so_list):
bom = frappe.qb.DocType("BOM")
so_item = frappe.qb.DocType("Sales Order Item")
pi = frappe.qb.DocType("Packed Item")
query = (
frappe.qb.from_(so_item)
.from_(pi)
.select(*_so_packed_columns(so_item, pi))
.distinct()
.where(_so_packed_filter(bom, so_item, pi, so_list))
)
if self.doc.item_code:
query = query.where(so_item.item_code == self.doc.item_code)
return query.run(as_dict=True)
def get_mr_items(self):
# Check for empty table or empty rows
if not self.doc.get("material_requests") or not self.get_so_mr_list(
"material_request", "material_requests"
):
frappe.throw(_("Please fill the Material Requests table"), title=_("Material Requests Required"))
mr_list = self.get_so_mr_list("material_request", "material_requests")
items = self._mr_items(mr_list)
self.add_items(items)
self.doc.calculate_total_planned_qty()
def _mr_items(self, mr_list):
bom = frappe.qb.DocType("BOM")
mr_item = frappe.qb.DocType("Material Request Item")
query = (
frappe.qb.from_(mr_item)
.select(*_mr_item_columns(mr_item))
.distinct()
.where(_mr_items_filter(bom, mr_item, mr_list))
)
if self.doc.item_code:
query = query.where(mr_item.item_code == self.doc.item_code)
return query.run(as_dict=True)
def add_items(self, items):
refs = {}
for data in items:
if not data.pending_qty:
continue
item_details = get_item_details(data.item_code, throw=False)
if self.doc.combine_items:
self._add_combine_ref(refs, data, item_details)
bom_no = data.bom_no or item_details and item_details.get("bom_no") or ""
if not bom_no:
continue
self._append_po_item(data, item_details, bom_no)
if refs:
self._apply_combined_refs(refs)
@staticmethod
def _add_combine_ref(refs, data, item_details):
bom_no = data.get("bom_no") or item_details.get("bom_no")
detail = {"sales_order": data.parent, "sales_order_item": data.name, "qty": data.pending_qty}
if bom_no in refs:
refs[bom_no]["so_details"].append(detail)
refs[bom_no]["qty"] += data.pending_qty
return
refs[bom_no] = {"qty": data.pending_qty, "po_item_ref": data.name, "so_details": [detail]}
def _append_po_item(self, data, item_details, bom_no):
pi = self.doc.append("po_items", self._po_item_values(data, item_details, bom_no))
pi._set_defaults()
if self.doc.get_items_from == "Sales Order":
pi.sales_order = data.parent
pi.sales_order_item = data.name
pi.description = data.description
elif self.doc.get_items_from == "Material Request":
pi.material_request = data.parent
pi.material_request_item = data.name
pi.description = data.description
@staticmethod
def _po_item_values(data, item_details, bom_no):
return {
"warehouse": data.warehouse,
"item_code": data.item_code,
"description": data.description or item_details.description,
"stock_uom": item_details and item_details.stock_uom or "",
"bom_no": bom_no,
"planned_qty": data.pending_qty,
"pending_qty": data.pending_qty,
"planned_start_date": now_datetime(),
"product_bundle_item": data.parent_item,
}
def _apply_combined_refs(self, refs):
for po_item in self.doc.po_items:
po_item.planned_qty = refs[po_item.bom_no]["qty"]
po_item.pending_qty = refs[po_item.bom_no]["qty"]
po_item.sales_order = ""
self.add_pp_ref(refs)
def add_pp_ref(self, refs):
for bom_no in refs:
for so_detail in refs[bom_no]["so_details"]:
self.doc.append(
"prod_plan_references",
{
"item_reference": refs[bom_no]["po_item_ref"],
"sales_order": so_detail["sales_order"],
"sales_order_item": so_detail["sales_order_item"],
"qty": so_detail["qty"],
},
)
def _so_item_columns(so_item):
return [
so_item.parent,
so_item.item_code,
so_item.warehouse,
(so_item.stock_qty - so_item.stock_reserved_qty).as_("qty"),
so_item.work_order_qty,
so_item.delivered_qty,
so_item.conversion_factor,
so_item.description,
so_item.name,
so_item.bom_no,
]
def _so_items_filter(so_item, so_list):
return (
(so_item.parent.isin(so_list))
& (so_item.docstatus == 1)
& ((so_item.stock_qty - so_item.stock_reserved_qty) > so_item.work_order_qty)
)
def _set_so_item_pending_qty(items):
for item in items:
item.pending_qty = flt(item.qty) - max(
item.work_order_qty, flt(item.delivered_qty) * item.conversion_factor, 0
)
def _so_packed_columns(so_item, pi):
pending_qty = (
frappe.qb.terms.Case()
.when(
(so_item.work_order_qty > so_item.delivered_qty),
(((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty),
)
.else_(((so_item.qty - so_item.delivered_qty) * pi.qty) / so_item.qty)
)
return [
pi.parent,
pi.item_code,
pi.warehouse.as_("warehouse"),
pending_qty.as_("pending_qty"),
pi.parent_item,
pi.description,
so_item.name,
]
def _so_packed_filter(bom, so_item, pi, so_list):
bom_exists = ExistsCriterion(
frappe.qb.from_(bom).select(bom.name).where((bom.item == pi.item_code) & (bom.is_active == 1))
)
pending = ((so_item.work_order_qty > so_item.delivered_qty) & (so_item.qty > so_item.work_order_qty)) | (
(so_item.work_order_qty <= so_item.delivered_qty) & (so_item.qty > so_item.delivered_qty)
)
return (
(so_item.parent == pi.parent)
& (so_item.docstatus == 1)
& (pi.parent_item == so_item.item_code)
& (so_item.parent.isin(so_list))
& pending
& bom_exists
)
def _mr_item_columns(mr_item):
return [
mr_item.parent,
mr_item.name,
mr_item.item_code,
mr_item.warehouse,
mr_item.description,
mr_item.bom_no,
((mr_item.qty - mr_item.ordered_qty) * mr_item.conversion_factor).as_("pending_qty"),
]
def _mr_items_filter(bom, mr_item, mr_list):
bom_exists = ExistsCriterion(
frappe.qb.from_(bom).select(bom.name).where((bom.item == mr_item.item_code) & (bom.is_active == 1))
)
return (
(mr_item.parent.isin(mr_list))
& (mr_item.docstatus == 1)
& (mr_item.qty > mr_item.ordered_qty)
& bom_exists
)

View File

@@ -0,0 +1,168 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Stock reservation for Production Plan (extracted from production_plan.py)."""
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import flt, parse_json
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
_RESERVATION_TABLES = {
"sub_assembly_items": {
"table_name": "sub_assembly_items",
"qty_field": "required_qty",
"warehouse_field": "fg_warehouse",
},
"mr_items": {
"table_name": "mr_items",
"qty_field": "required_bom_qty",
"warehouse_field": "warehouse",
},
}
def get_reserved_qty_for_production_plan(item_code, warehouse):
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
non_completed_production_plans = get_non_completed_production_plans()
reserved = _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans)
if reserved is None:
return None
for_production = flt(
get_reserved_qty_for_production(
item_code, warehouse, non_completed_production_plans, check_production_plan=True
)
)
if for_production > reserved:
return 0.0
return reserved - for_production
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Material Request Plan Item")
qty = (
Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)
* child.conversion_factor
)
query = (
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select(Sum(qty))
.where(_plan_reserved_filter(table, child, item_code, warehouse))
)
if non_completed_production_plans:
query = query.where(table.name.isin(non_completed_production_plans))
result = query.run()
return flt(result[0][0]) if result and result[0][0] is not None else None
def _plan_reserved_filter(table, child, item_code, warehouse):
return (
(table.docstatus == 1)
& (child.item_code == item_code)
& (child.warehouse == warehouse)
& (table.status.notin(["Completed", "Closed"]))
)
def get_non_completed_production_plans():
table = frappe.qb.DocType("Production Plan")
return (
frappe.qb.from_(table)
.select(table.name)
.distinct()
.where((table.docstatus == 1) & (table.status.notin(["Completed", "Closed"])))
).run(pluck="name")
def get_reserved_qty_for_sub_assembly(item_code, warehouse):
table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Production Plan Sub Assembly Item")
qty_field = Case().when(child.qty > 0, child.qty).else_(child.required_qty) - IfNull(
child.wo_produced_qty, 0
)
result = (
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select(Sum(qty_field))
.where(_sub_assembly_reserved_filter(table, child, item_code, warehouse))
).run()
if not result or result[0][0] is None:
return None
qty = flt(result[0][0])
return qty if qty > 0 else 0.0
def _sub_assembly_reserved_filter(table, child, item_code, warehouse):
return (
(table.docstatus == 1)
& (child.production_item == item_code)
& (child.fg_warehouse == warehouse)
& (table.status.notin(["Completed", "Closed"]))
)
@frappe.whitelist()
def make_stock_reservation_entries(
doc: str | Document, items: str | list | None = None, table_name: str | None = None, notify: bool = False
):
"""Whitelisted entry point: verify Production Plan write access, then reserve stock."""
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
reserve_stock_for_production_plan(doc, items=items, table_name=table_name, notify=notify)
def reserve_stock_for_production_plan(
doc: Document, items: str | list | None = None, table_name: str | None = None, notify: bool = False
):
"""Reserve stock for a Production Plan. Internal: no permission check (also called
from the Production Plan submit/cancel lifecycle)."""
if items and isinstance(items, str):
items = parse_json(items)
for child_table_name, kwargs in _RESERVATION_TABLES.items():
if table_name and table_name != child_table_name:
continue
_reserve_or_cancel_plan_table(doc, items, kwargs)
doc.reload()
def _reserve_or_cancel_plan_table(doc, items, kwargs):
sre = StockReservation(doc, items=items, kwargs=kwargs)
if doc.docstatus == 1:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif doc.docstatus == 2:
sre.cancel_stock_reservation_entries()
@frappe.whitelist()
def cancel_stock_reservation_entries(doc: str | Document, sre_list: str | list):
"""Whitelisted entry point: verify Production Plan write access, then cancel reservations."""
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
sre = StockReservation(doc)
sre.cancel_stock_reservation_entries(sre_list)
doc.reload()

View File

@@ -0,0 +1,181 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Sub-assembly item resolution for a Production Plan (extracted from production_plan.py)."""
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_planning import (
get_sub_assembly_items,
)
class SubAssemblyService:
def __init__(self, doc):
self.doc = doc
def get_sub_assembly_items(self, manufacturing_type: str | None = None):
"Fetch sub assembly items and optionally combine them."
self.doc.sub_assembly_items = []
sub_assembly_items_store = [] # temporary store to process all subassembly items
bin_details = frappe._dict()
processed_any = False
for row in self.doc.po_items:
if self._collect_row_sub_assembly_items(
row, sub_assembly_items_store, bin_details, manufacturing_type
):
processed_any = True
if processed_any and not sub_assembly_items_store and self.doc.skip_available_sub_assembly_item:
self._warn_sufficient_sub_assembly()
if self.doc.combine_sub_items:
sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store)
for idx, row in enumerate(sub_assembly_items_store):
row.idx = idx + 1
self.doc.append("sub_assembly_items", row)
self.set_default_supplier_for_subcontracting_order()
def _collect_row_sub_assembly_items(self, row, sub_assembly_items_store, bin_details, manufacturing_type):
self._validate_sub_assembly_row(row)
if self._bom_tracks_semi_finished(row):
return False
bom_data = []
get_sub_assembly_items(
[item.production_item for item in sub_assembly_items_store],
bin_details,
row.bom_no,
bom_data,
row.planned_qty,
self.doc.company,
warehouse=self.doc.sub_assembly_warehouse,
skip_available_sub_assembly_item=self.doc.skip_available_sub_assembly_item,
)
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
sub_assembly_items_store.extend(bom_data)
return True
@staticmethod
def _bom_tracks_semi_finished(row):
if not frappe.db.get_value("BOM", row.bom_no, "track_semi_finished_goods"):
return False
frappe.msgprint(
_(
"Row #{0}: Since 'Track Semi Finished Goods' is enabled, the BOM {1} cannot be used for Sub Assembly Items"
).format(row.idx, row.bom_no)
)
return True
def _validate_sub_assembly_row(self, row):
if self.doc.skip_available_sub_assembly_item and not self.doc.sub_assembly_warehouse:
frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx))
if not row.item_code:
frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
if not row.bom_no:
frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx))
def _warn_sufficient_sub_assembly(self):
label = self.meta.get_field("skip_available_sub_assembly_item").label
message = (
_(
"As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
).format(self.doc.sub_assembly_warehouse)
+ "<br><br>"
)
message += _("If you still want to proceed, please disable {0} checkbox.").format(label)
frappe.msgprint(message, title=_("Note"))
def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
"Modify bom_data, set additional details."
is_group_warehouse = frappe.db.get_value("Warehouse", self.doc.sub_assembly_warehouse, "is_group")
for data in bom_data:
data.qty = data.stock_qty
data.production_plan_item = row.name
data.schedule_date = row.planned_start_date
data.type_of_manufacturing = manufacturing_type or (
"Subcontract" if data.is_sub_contracted_item else "In House"
)
if not is_group_warehouse:
data.fg_warehouse = self.doc.sub_assembly_warehouse
if not self.doc.combine_sub_items:
data.sales_order = row.sales_order
data.sales_order_item = row.sales_order_item
def set_default_supplier_for_subcontracting_order(self):
items = [
d.production_item for d in self.doc.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
]
if not items:
return
default_supplier = self._default_suppliers(items)
if not default_supplier:
return
for row in self.doc.sub_assembly_items:
if row.type_of_manufacturing == "Subcontract":
row.supplier = default_supplier.get(row.production_item)
@staticmethod
def _default_suppliers(items):
return frappe._dict(
frappe.get_all(
"Item Default",
fields=["parent", "default_supplier"],
filters={"parent": ("in", items), "default_supplier": ("is", "set")},
as_list=1,
)
)
def combine_subassembly_items(self, sub_assembly_items_store):
"Aggregate if same: Item, Warehouse, Inhouse/Outhouse Manu.g, BOM No."
key_wise_data = {}
for row in sub_assembly_items_store:
key = (
row.get("production_item"),
row.get("fg_warehouse"),
row.get("bom_no"),
row.get("type_of_manufacturing"),
)
existing_row = key_wise_data.get(key)
if existing_row:
self._merge_subassembly_row(existing_row, row)
else:
key_wise_data[key] = row
return list(key_wise_data.values())
@staticmethod
def _merge_subassembly_row(existing_row, row):
existing_row.qty += flt(row.qty)
existing_row.stock_qty += flt(row.stock_qty)
existing_row.bom_level = max(existing_row.bom_level, row.bom_level)
def all_items_completed(self):
all_items_produced = all(
flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 for d in self.doc.po_items
)
if not all_items_produced:
return False
wo_status = frappe.get_all(
"Work Order",
filters={
"production_plan": self.doc.name,
"status": ("not in", ["Closed", "Stopped"]),
"docstatus": 1,
},
fields="status",
pluck="status",
)
return all(s == "Completed" for s in wo_status)

View File

@@ -0,0 +1,255 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Sub-assembly resolution helpers for Production Plan."""
import frappe
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import flt
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import (
get_bin_details,
get_uom_conversion_factor,
)
def get_sub_assembly_items(
sub_assembly_items,
bin_details,
bom_no,
bom_data,
to_produce_qty,
company,
warehouse=None,
indent=0,
skip_available_sub_assembly_item=False,
):
precision = frappe.get_precision("Production Plan Sub Assembly Item", "qty")
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
for d in get_bom_children(parent=bom_no):
if not d.expandable:
continue
stock_qty = _add_sub_assembly_child(
d,
parent_item_code,
bom_no,
bom_data,
sub_assembly_items,
bin_details,
to_produce_qty,
company,
warehouse,
indent,
precision,
skip_available_sub_assembly_item,
)
if d.value:
get_sub_assembly_items(
sub_assembly_items,
bin_details,
d.value,
bom_data,
stock_qty,
company,
warehouse,
indent=indent + 1,
skip_available_sub_assembly_item=skip_available_sub_assembly_item,
)
def _add_sub_assembly_child(
d,
parent_item_code,
bom_no,
bom_data,
sub_assembly_items,
bin_details,
to_produce_qty,
company,
warehouse,
indent,
precision,
skip_available,
):
required_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
stock_qty = _resolve_available_sub_assembly(
d, required_qty, sub_assembly_items, bin_details, company, warehouse, skip_available
)
if not d.is_phantom_item:
bom_data.append(
_sub_assembly_row(
d, parent_item_code, bom_no, bin_details, stock_qty, required_qty, indent, precision
)
)
return stock_qty
def _resolve_available_sub_assembly(
d, stock_qty, sub_assembly_items, bin_details, company, warehouse, skip_available
):
if skip_available and d.item_code not in sub_assembly_items:
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
return _consume_projected_qty(d, stock_qty, sub_assembly_items, bin_details)
if warehouse:
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
return stock_qty
def _consume_projected_qty(d, stock_qty, sub_assembly_items, bin_details):
for _bin_dict in bin_details[d.item_code]:
_bin_dict.original_projected_qty = _bin_dict.projected_qty
if _bin_dict.original_projected_qty <= 0:
continue
if _bin_dict.original_projected_qty >= stock_qty:
_bin_dict.original_projected_qty -= stock_qty
stock_qty = 0
continue
stock_qty -= _bin_dict.original_projected_qty
sub_assembly_items.append(d.item_code)
return stock_qty
def _sub_assembly_row(d, parent_item_code, bom_no, bin_details, stock_qty, required_qty, indent, precision):
bins = bin_details.get(d.item_code)
actual_qty = bins[0].get("actual_qty", 0) if bins else 0
projected_qty = bins[0].get("projected_qty", 0) if bins else 0
return frappe._dict(
{
"actual_qty": actual_qty,
"parent_item_code": parent_item_code,
"description": d.description,
"production_item": d.item_code,
"item_name": d.item_name,
"stock_uom": d.stock_uom,
"uom": d.stock_uom,
"bom_no": d.value,
"is_sub_contracted_item": d.is_sub_contracted_item,
"bom_level": indent,
"indent": indent,
"stock_qty": flt(stock_qty, precision),
"required_qty": flt(required_qty, precision),
"projected_qty": projected_qty,
"main_bom": bom_no,
}
)
def get_raw_materials_of_sub_assembly_items(
existing_sub_assembly_items,
item_details,
company,
bom_no,
include_non_stock_items,
sub_assembly_items,
planned_qty=1,
):
for item in _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty):
_process_sub_assembly_rm(
item,
existing_sub_assembly_items,
item_details,
company,
include_non_stock_items,
sub_assembly_items,
)
return item_details
def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty):
bei = frappe.qb.DocType("BOM Item")
bom = frappe.qb.DocType("BOM")
item = frappe.qb.DocType("Item")
item_default = frappe.qb.DocType("Item Default")
item_uom = frappe.qb.DocType("UOM Conversion Detail")
return (
frappe.qb.from_(bei)
.join(bom)
.on(bom.name == bei.parent)
.join(item)
.on(item.name == bei.item_code)
.left_join(item_default)
.on((item_default.parent == item.name) & (item_default.company == company))
.left_join(item_uom)
.on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
.select(*_sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty))
.where(_sub_assembly_rm_filter(bei, bom, item, bom_no, include_non_stock_items))
.groupby(bei.item_code, bei.stock_uom)
).run(as_dict=True)
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
return [
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
item.item_name,
item.name.as_("item_code"),
bei.description,
bei.stock_uom,
bei.is_phantom_item,
bei.bom_no,
item.min_order_qty,
bei.source_warehouse,
item.default_material_request_type,
item.min_order_qty,
item_default.default_warehouse,
item.purchase_uom,
item_uom.conversion_factor,
item.safety_stock,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
]
def _sub_assembly_rm_filter(bei, bom, item, bom_no, include_non_stock_items):
stock_filter = item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1
return (
(bei.docstatus == 1)
& (bei.is_sub_assembly_item == 0)
& (bom.name == bom_no)
& (stock_filter | (bei.is_phantom_item == 1))
)
def _process_sub_assembly_rm(
item, existing_sub_assembly_items, item_details, company, include_non_stock_items, sub_assembly_items
):
key = (item.item_code, item.bom_no)
existing_key = (item.item_code, item.bom_no or item.main_bom)
if item.bom_no and not item.is_phantom_item and key not in sub_assembly_items:
return
if not item.is_phantom_item and existing_key in existing_sub_assembly_items:
return
if not item.bom_no:
_merge_sub_assembly_rm(item, item_details)
return
recursion_qty = flt(item.get("qty")) if item.is_phantom_item else flt(sub_assembly_items[key])
get_raw_materials_of_sub_assembly_items(
existing_sub_assembly_items,
item_details,
company,
item.bom_no,
include_non_stock_items,
sub_assembly_items,
planned_qty=recursion_qty,
)
if not item.is_phantom_item:
existing_sub_assembly_items.add(existing_key)
def _merge_sub_assembly_rm(item, item_details):
if not item.conversion_factor and item.purchase_uom:
item.conversion_factor = get_uom_conversion_factor(item.item_code, item.purchase_uom)
key = (item.get("item_code"), item.get("main_bom"))
if details := item_details.get(key):
details.qty += item.get("qty")
else:
item_details.setdefault(key, item)

View File

@@ -0,0 +1,243 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Work Order / subcontract PO creation from a Production Plan (extracted from production_plan.py)."""
from collections import defaultdict
import frappe
from frappe import _, msgprint
from frappe.utils import flt, get_filtered_list_link, getdate, nowdate
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import set_default_warehouses
_SUB_ASSEMBLY_WO_FIELDS = [
"production_item",
"item_name",
"fg_warehouse",
"description",
"bom_no",
"stock_uom",
"bom_level",
"schedule_date",
"sales_order",
"sales_order_item",
]
_SUBCONTRACT_PO_ITEM_FIELDS = [
"schedule_date",
"qty",
"description",
"production_plan_item",
"sales_order",
"sales_order_item",
]
class WorkOrderCreationService:
def __init__(self, doc):
self.doc = doc
def get_production_items(self):
item_dict = {}
for d in self.doc.po_items:
item_details = self._production_item_details(d)
if self.doc.get_items_from == "Material Request":
item_details["qty"] = d.planned_qty
key = (d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
item_dict[key] = item_details
else:
key = self._production_item_key(d)
existing = flt(item_dict.get(key, {}).get("qty"))
item_details["qty"] = existing + (flt(d.planned_qty) - flt(d.ordered_qty))
item_dict[key] = item_details
return item_dict
def _production_item_details(self, d):
details = {
"production_item": d.item_code,
"use_multi_level_bom": d.include_exploded_items,
"sales_order": d.sales_order,
"sales_order_item": d.sales_order_item,
"material_request": d.material_request,
"material_request_item": d.material_request_item,
"bom_no": d.bom_no,
"description": d.description,
"stock_uom": d.stock_uom,
"company": self.doc.company,
"fg_warehouse": d.warehouse,
"production_plan": self.doc.name,
"production_plan_item": d.name,
"product_bundle_item": d.product_bundle_item,
"planned_start_date": d.planned_start_date,
"project": self.doc.project,
"source_warehouse": frappe.get_value("BOM", d.bom_no, "default_source_warehouse"),
}
if not details["project"] and d.sales_order:
details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
return details
def _production_item_key(self, d):
if not d.sales_order:
return (d.name, d.item_code, d.warehouse, d.planned_start_date)
if self.doc.combine_items:
return (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
return (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
wo_list, po_list = [], []
subcontracted_po = {}
default_warehouses = get_default_warehouse(self.doc.company)
self.make_work_order_for_finished_goods(wo_list, default_warehouses)
self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message("Work Order", wo_list)
self.show_list_created_message("Purchase Order", po_list)
if not wo_list:
frappe.msgprint(_("No Work Orders were created"))
if not po_list:
frappe.msgprint(_("No Purchase Orders were created"))
def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
for _key, item in self.get_production_items().items():
if self.doc.sub_assembly_items:
item["use_multi_level_bom"] = 0
set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item)
if work_order:
wo_list.append(work_order)
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
for row in self.doc.sub_assembly_items:
if row.type_of_manufacturing == "Subcontract":
subcontracted_po.setdefault(row.supplier, []).append(row)
continue
if row.type_of_manufacturing == "Material Request":
continue
work_order = self._sub_assembly_work_order(row, default_warehouses)
if work_order:
wo_list.append(work_order)
def _sub_assembly_work_order(self, row, default_warehouses):
if flt(row.qty) <= flt(row.ordered_qty):
return None
work_order_data = {
"source_warehouse": frappe.get_value("BOM", row.bom_no, "default_source_warehouse"),
"wip_warehouse": default_warehouses.get("wip_warehouse"),
"fg_warehouse": default_warehouses.get("fg_warehouse"),
"scrap_warehouse": default_warehouses.get("scrap_warehouse"),
"company": self.doc.get("company"),
}
self.prepare_data_for_sub_assembly_items(row, work_order_data)
if work_order_data.get("qty") <= 0:
return None
return self.create_work_order(work_order_data)
def prepare_data_for_sub_assembly_items(self, row, wo_data):
for field in _SUB_ASSEMBLY_WO_FIELDS:
if row.get(field):
wo_data[field] = row.get(field)
wo_data["qty"] = flt(row.get("qty")) - flt(row.get("ordered_qty"))
wo_data.update(
{
"use_multi_level_bom": 0,
"production_plan": self.doc.name,
"production_plan_sub_assembly_item": row.name,
}
)
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po:
return
subcontracted_po = _consolidate_subcontracted_po(subcontracted_po)
for supplier, po_list in subcontracted_po.items():
po = self._create_subcontract_po(supplier, po_list)
purchase_orders.append(po.name)
def _create_subcontract_po(self, supplier, po_list):
po = frappe.new_doc("Purchase Order")
po.company = self.doc.company
po.supplier = supplier
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 1
for row in po_list:
po.append("items", self._subcontract_po_item(row))
po.set_service_items_for_finished_goods()
po.set_missing_values()
po.flags.ignore_mandatory = True
po.flags.ignore_validate = True
po.insert()
return po
def _subcontract_po_item(self, row):
po_data = {
"fg_item": row.production_item,
"warehouse": row.fg_warehouse,
"production_plan_sub_assembly_item": row.name,
"bom": row.bom_no,
"production_plan": self.doc.name,
"fg_item_qty": row.qty,
}
for field in _SUBCONTRACT_PO_ITEM_FIELDS:
po_data[field] = row.get(field)
return po_data
def show_list_created_message(self, doctype, doc_list=None):
if not doc_list:
return
frappe.flags.mute_messages = False
msgprint(_("{0} created").format(get_filtered_list_link(doctype, doc_list)))
def create_work_order(self, item):
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
if flt(item.get("qty")) <= 0:
return
wo = self._new_work_order(item)
try:
wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.company = self.doc.company
wo.insert()
return wo.name
except OverProductionError:
pass
def _new_work_order(self, item):
wo = frappe.new_doc("Work Order")
wo.update(item)
if not wo.source_warehouse:
wo.source_warehouse = item.get("fg_warehouse")
wo.reserve_stock = self.doc.reserve_stock
wo.planned_start_date = item.get("planned_start_date") or item.get("schedule_date")
if item.get("warehouse"):
wo.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations()
wo.set_required_items(reset_source_warehouse=True)
return wo
def _consolidate_subcontracted_po(subcontracted_po):
items_to_remove = defaultdict(list)
for supplier, items in subcontracted_po.items():
for item in items:
if item.qty == item.received_qty:
items_to_remove[supplier].append(item)
elif item.received_qty:
item.qty -= item.received_qty
subcontracted_po[supplier] = [item for item in items if item not in items_to_remove[supplier]]
return {key: value for key, value in subcontracted_po.items() if value}

View File

@@ -1,11 +1,225 @@
# 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 flt
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):
return (
item_table.end_of_life.isnull()
| (item_table.end_of_life == "0000-00-00")
| (item_table.end_of_life > nowdate())
)
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"):
if isinstance(variant_items, str):
variant_items = json.loads(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()
@@ -17,15 +231,23 @@ def make_stock_entry(
is_additional_transfer_entry: bool = False,
source_stock_entry: str | None = None,
):
work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
wip_warehouse = work_order.wip_warehouse
else:
wip_warehouse = 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_id
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
@@ -36,18 +258,20 @@ def make_stock_entry(
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
stock_entry.project = work_order.project
else:
stock_entry.from_warehouse = (
work_order.source_warehouse
if work_order.skip_transfer and not work_order.from_wip_warehouse
else wip_warehouse
)
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
stock_entry.project = work_order.project
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value(
"BOM", work_order.bom_no, "inspection_required"
@@ -59,65 +283,247 @@ def make_stock_entry(
if source_stock_entry:
stock_entry.source_stock_entry = 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()
@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)
if isinstance(operations, str):
operations = json.loads(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 | None = None, for_qty: float | None = None):
frappe.has_permission("Pick List", "create", throw=True)
for_qty = for_qty or json.loads(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
def update_item_quantity(source, target, source_parent):
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 qty:
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
else:
target.delete()
doc = get_mapped_doc(
"Work Order",
source_name,
{
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"postprocess": update_item_quantity,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},
},
target_doc,
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",
"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_stock_return_entry(work_order: str):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.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")

View File

@@ -0,0 +1,296 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Operation scheduling, costing and job-card preparation for Work Order.
Extracted from work_order.py. ``OperationsService`` wraps a Work Order document
(composition); work_order.py keeps thin delegating stubs for the methods that
are called from other modules.
"""
import frappe
from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.utils import (
cint,
date_diff,
flt,
get_datetime,
get_link_to_form,
getdate,
time_diff_in_hours,
)
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
from erpnext.manufacturing.doctype.work_order.mapper import (
create_job_card,
split_qty_based_on_batch_size,
)
_BOM_OPERATION_FIELDS = [
"operation",
"description",
"workstation",
"idx",
"finished_good",
"is_subcontracted",
"wip_warehouse",
"source_warehouse",
"fg_warehouse",
"workstation_type",
"base_hour_rate as hour_rate",
"time_in_mins",
"parent as bom",
"bom_no",
"batch_size",
"sequence_id",
"fixed_time",
"skip_material_transfer",
"backflush_from_wip_warehouse",
"set_cost_based_on_bom_qty",
"quality_inspection_required",
]
class OperationsService:
def __init__(self, doc):
self.doc = doc
def calculate_operating_cost(self):
self.doc.planned_operating_cost, self.doc.actual_operating_cost = 0.0, 0.0
for d in self.doc.get("operations"):
self._set_operation_cost(d)
self.doc.planned_operating_cost += flt(d.planned_operating_cost)
self.doc.actual_operating_cost += flt(d.actual_operating_cost)
variable_cost = self.doc.actual_operating_cost or self.doc.planned_operating_cost
self.doc.total_operating_cost = (
flt(self.doc.additional_operating_cost)
+ flt(variable_cost)
+ flt(self.doc.corrective_operation_cost)
)
@staticmethod
def _set_operation_cost(d):
if not d.hour_rate and d.workstation:
d.hour_rate = get_hour_rate(d.workstation)
d.planned_operating_cost = flt(
flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost")
)
d.actual_operating_cost = flt(
flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost")
)
def create_job_card(self):
manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings")
enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning)
plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30
for idx, row in enumerate(self.doc.operations):
qty = self.doc.qty
while qty > 0:
qty = split_qty_based_on_batch_size(self.doc, row, qty)
if row.job_card_qty > 0:
self.prepare_data_for_job_card(row, idx, plan_days, enable_capacity_planning)
planned_end_date = self.doc.operations and self.doc.operations[-1].planned_end_time
if planned_end_date:
self.doc.db_set("planned_end_date", planned_end_date)
def prepare_data_for_job_card(self, row, idx, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(row, idx)
job_card_doc = create_job_card(
self.doc, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
)
if enable_capacity_planning and job_card_doc:
row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time
row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time
self._validate_capacity_window(row, plan_days)
row.db_update()
def _validate_capacity_window(self, row, plan_days):
from erpnext.manufacturing.doctype.work_order.work_order import CapacityError
if date_diff(row.planned_end_time, self.doc.planned_start_date) <= plan_days:
return
frappe.message_log.pop()
msg = _(
"Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}."
).format(
plan_days, row.operation, get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
)
frappe.throw(msg, CapacityError)
def set_operation_start_end_time(self, row, idx):
"""Set start and end time for given operation. If first operation, set start as
`planned_start_date`, else add time diff to end time of earlier operation."""
if idx == 0:
# first operation at planned_start date
row.planned_start_time = self.doc.planned_start_date
elif self.doc.operations[idx - 1].sequence_id:
row.planned_start_time = self._sequence_based_start_time(row, idx)
else:
previous_end = get_datetime(self.doc.operations[idx - 1].planned_end_time)
row.planned_start_time = previous_end + get_mins_between_operations()
row.planned_end_time = get_datetime(row.planned_start_time) + relativedelta(minutes=row.time_in_mins)
if row.planned_start_time == row.planned_end_time:
frappe.throw(_("Capacity Planning Error, planned start time can not be same as end time"))
def _sequence_based_start_time(self, row, idx):
previous = self.doc.operations[idx - 1]
if previous.sequence_id == row.sequence_id:
return previous.planned_start_time
same_sequence = sorted(
[op for op in self.doc.operations if op.sequence_id == previous.sequence_id],
key=lambda op: get_datetime(op.planned_end_time),
)
return get_datetime(same_sequence[-1].planned_end_time) + get_mins_between_operations()
def set_work_order_operations(self):
"""Fetch operations from BOM and set in 'Work Order'"""
self.doc.set("operations", [])
if not self.doc.bom_no or not frappe.get_cached_value("BOM", self.doc.bom_no, "with_operations"):
return
operations = self._collect_bom_operations()
for correct_index, operation in enumerate(operations, start=1):
operation.idx = correct_index
self.doc.set("operations", operations)
self.calculate_time()
def _collect_bom_operations(self):
operations = []
if self.doc.use_multi_level_bom:
bom_tree = frappe.get_doc("BOM", self.doc.bom_no).get_tree_representation()
for node in reversed(bom_tree.level_order_traversal()):
if node.is_bom:
qty = node.exploded_qty / node.bom_qty
operations.extend(self._bom_operations(node.name, qty=qty, exploded=True))
bom_qty = frappe.get_cached_value("BOM", self.doc.bom_no, "quantity")
operations.extend(self._bom_operations(self.doc.bom_no, qty=bom_qty))
return operations
def _bom_operations(self, bom_no, qty=1, exploded=False):
data = frappe.get_all(
"BOM Operation", filters={"parent": bom_no}, fields=_BOM_OPERATION_FIELDS, order_by="idx"
)
for d in data:
self._adjust_operation_row(d, qty, exploded)
return data
def _adjust_operation_row(self, d, qty, exploded):
if not d.fixed_time:
if frappe.get_value("Operation", d.operation, "create_job_card_based_on_batch_size"):
qty = d.batch_size
d.time_in_mins = d.time_in_mins * flt(qty) if exploded else d.time_in_mins / flt(qty)
d.status = "Pending"
if self.doc.track_semi_finished_goods and not d.sequence_id:
d.sequence_id = d.idx
def calculate_time(self):
for d in self.doc.get("operations"):
if not d.fixed_time:
d.time_in_mins = flt(d.time_in_mins) * flt(self.doc.qty)
self.calculate_operating_cost()
def get_holidays(self, workstation):
holiday_list = frappe.db.get_value("Workstation", workstation, "holiday_list")
holidays = {}
if holiday_list not in holidays:
holiday_list_days = [
getdate(d[0])
for d in frappe.get_all(
"Holiday",
fields=["holiday_date"],
filters={"parent": holiday_list},
order_by="holiday_date",
limit_page_length=0,
as_list=1,
)
]
holidays[holiday_list] = holiday_list_days
return holidays[holiday_list]
def update_operation_status(self):
allowance_percentage = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)
max_allowed_qty_for_wo = flt(self.doc.qty) + (allowance_percentage / 100 * flt(self.doc.qty))
for d in self.doc.get("operations"):
d.status = self._operation_status(d, max_allowed_qty_for_wo)
def _operation_status(self, d, max_allowed_qty_for_wo):
precision = d.precision("completed_qty")
qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
if not qty:
return "Pending"
if qty < flt(self.doc.qty, precision):
return "Work in Progress"
if qty <= flt(max_allowed_qty_for_wo, precision):
return "Completed"
frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
def set_actual_dates(self):
if self.doc.get("operations"):
self._set_dates_from_operations()
else:
self._set_dates_from_stock_entries()
self.set_lead_time()
def _set_dates_from_operations(self):
operations = self.doc.get("operations")
start_dates = [d.actual_start_time for d in operations if d.actual_start_time]
if start_dates:
self.doc.actual_start_date = min(start_dates)
end_dates = [d.actual_end_time for d in operations if d.actual_end_time]
if end_dates:
self.doc.actual_end_date = max(end_dates)
def _set_dates_from_stock_entries(self):
data = frappe.get_all(
"Stock Entry",
fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}],
filters={
"work_order": self.doc.name,
"purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]),
},
)
if not data:
return
dates = [d.posting_datetime for d in data]
self.doc.db_set("actual_start_date", min(dates))
if self.doc.status == "Completed":
self.doc.db_set("actual_end_date", max(dates))
def set_lead_time(self):
if self.doc.actual_start_date and self.doc.actual_end_date:
self.doc.lead_time = flt(
time_diff_in_hours(self.doc.actual_end_date, self.doc.actual_start_date) * 60
)
@frappe.request_cache
def get_hour_rate(workstation):
return frappe.get_cached_value("Workstation", workstation, "hour_rate") or 0.0

View File

@@ -0,0 +1,240 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Required-items (raw material) management for Work Order.
Extracted from work_order.py. ``RequiredItemsService`` wraps a Work Order
document (composition); work_order.py keeps thin delegating stubs so external
callers and the whitelisted entry point keep working unchanged.
"""
import frappe
from frappe.utils import flt
from pypika import functions as fn
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.manufacturing.doctype.work_order.mapper import check_if_scrap_warehouse_mandatory
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
get_consumed_qty,
get_row_wise_serial_batch,
)
from erpnext.stock.utils import get_bin, get_latest_stock_qty
class RequiredItemsService:
def __init__(self, doc):
self.doc = doc
def update_required_items(self):
"""
update bin reserved_qty_for_production
called from Stock Entry for production, after submit, cancel
"""
if self.doc.docstatus == 1:
self.update_returned_qty()
# calculate consumed qty based on submitted stock entries
self.update_consumed_qty_for_required_items()
if self.doc.docstatus == 1:
# calculate transferred qty based on submitted stock entries
self.update_transferred_qty_for_required_items()
# update in bin
self.update_reserved_qty_for_production()
StockReservationService(self.doc).validate_reserved_qty()
def update_reserved_qty_for_production(self, items=None):
"""update reserved_qty_for_production in bins"""
for d in self.doc.required_items:
if d.source_warehouse:
stock_bin = get_bin(d.item_code, d.source_warehouse)
stock_bin.update_reserved_qty_for_production()
def get_items_and_operations_from_bom(self):
self.set_required_items()
self.doc.set_work_order_operations()
return check_if_scrap_warehouse_mandatory(self.doc.bom_no)
def set_available_qty(self):
for d in self.doc.get("required_items"):
if d.source_warehouse:
d.available_qty_at_source_warehouse = get_latest_stock_qty(d.item_code, d.source_warehouse)
if self.doc.wip_warehouse:
d.available_qty_at_wip_warehouse = get_latest_stock_qty(d.item_code, self.doc.wip_warehouse)
def set_required_items(self, reset_only_qty=False, reset_source_warehouse=False):
"""set required_items for production to keep track of reserved qty"""
if not reset_only_qty:
self.doc.required_items = []
if not (self.doc.bom_no and self.doc.qty):
return
operations = self.doc.get("operations") or []
operation = operations[0].operation if len(operations) == 1 else None
item_dict = get_bom_items_as_dict(
self.doc.bom_no, self.doc.company, qty=self.doc.qty, fetch_exploded=self.doc.use_multi_level_bom
)
if reset_only_qty:
self._reset_required_qty(item_dict, operation)
else:
self._append_required_items(item_dict, operation, reset_source_warehouse)
self.set_available_qty()
def _reset_required_qty(self, item_dict, operation):
for d in self.doc.get("required_items"):
if item_dict.get(d.item_code):
d.required_qty = item_dict.get(d.item_code).get("qty")
if not d.operation:
d.operation = operation
def _append_required_items(self, item_dict, operation, reset_source_warehouse):
for item in sorted(item_dict.values(), key=lambda d: d["idx"] or float("inf")):
source_warehouse = self._item_source_warehouse(item, reset_source_warehouse)
self.doc.append("required_items", self._required_item_row(item, operation, source_warehouse))
if self.doc.subcontracting_inward_order and not frappe.get_cached_value(
"Item", item.item_code, "is_customer_provided_item"
):
self.doc.required_items[-1].source_warehouse = item.default_warehouse
if not self.doc.project:
self.doc.project = item.get("project")
def _item_source_warehouse(self, item, reset_source_warehouse):
if reset_source_warehouse:
return self.doc.source_warehouse
return self.doc.source_warehouse or item.source_warehouse or item.default_warehouse
def _required_item_row(self, item, operation, source_warehouse):
return {
"rate": item.rate,
"amount": item.rate * item.qty,
"operation": item.operation or operation,
"item_code": item.item_code,
"item_name": item.item_name,
"stock_uom": item.stock_uom,
"description": item.description,
"allow_alternative_item": item.allow_alternative_item,
"required_qty": item.qty,
"source_warehouse": source_warehouse,
"include_item_in_manufacturing": item.include_item_in_manufacturing,
"operation_row_id": item.operation_row_id,
}
def update_transferred_qty_for_required_items(self):
if self.doc.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
row_wise_serial_batch = frappe._dict({})
if self.doc.reserve_stock:
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name)
for row in self.doc.required_items:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.doc.reserve_stock:
StockReservationService(self.doc).update_qty_in_stock_reservation(
row, transferred_qty, row_wise_serial_batch
)
def update_returned_qty(self):
returned_dict = self._material_transfer_qty_by_item(is_return=1)
for row in self.doc.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
query = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(ste_child.item_code, ste_child.original_item, fn.Sum(ste_child.transfer_qty).as_("qty"))
.where(self._material_transfer_filter(ste, is_return))
.groupby(ste_child.item_code)
)
return frappe._dict({d.original_item or d.item_code: d.qty for d in (query.run(as_dict=1) or [])})
def _material_transfer_filter(self, ste, is_return):
return (
(ste.docstatus == 1)
& (ste.work_order == self.doc.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == is_return)
)
def update_consumed_qty_for_required_items(self):
"""
Update consumed qty from submitted stock entries
against a work order for each stock item
"""
wip_warehouse = self.doc.wip_warehouse
if self.doc.skip_transfer and not self.doc.from_wip_warehouse:
wip_warehouse = None
for item in self.doc.required_items:
consumed_qty = get_consumed_qty(self.doc.name, item.item_code) + item.returned_qty
item.db_set("consumed_qty", flt(consumed_qty), update_modified=False)
if not self.doc.reserve_stock:
continue
warehouse = wip_warehouse or item.source_warehouse
StockReservationService(self.doc).update_consumed_qty_in_stock_reservation(
item, consumed_qty, warehouse
)
def remove_additional_items(self, stock_entry):
for row in stock_entry.items:
for item in self.doc.required_items:
if row.item_code == item.item_code and row.name == item.voucher_detail_reference:
item.delete()
def add_additional_items(self, stock_entry):
if frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
return
if stock_entry.purpose != "Material Transfer for Manufacture":
return
additional_items = self._additional_items_by_code(stock_entry)
self.doc.flags.ignore_validate_update_after_submit = True
for rows in additional_items.values():
for row in rows:
self.doc.append("required_items", self._additional_item_row(row))
self.doc.save()
stock_entry.reload()
def _additional_items_by_code(self, stock_entry):
required_items = [d.item_code for d in self.doc.required_items]
additional_items = frappe._dict()
for row in stock_entry.items:
item_code = row.original_item if row.original_item else row.item_code
if item_code not in required_items:
additional_items.setdefault(item_code, []).append(row)
return additional_items
@staticmethod
def _additional_item_row(row):
return {
"item_code": row.original_item or row.item_code,
"source_warehouse": row.s_warehouse,
"item_name": row.item_name,
"required_qty": row.transfer_qty,
"stock_uom": row.stock_uom,
"rate": row.basic_rate,
"amount": row.amount,
"description": row.description,
"is_additional_item": 1,
"voucher_detail_reference": row.name,
}

View File

@@ -0,0 +1,424 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Status and quantity-rollup logic for Work Order.
Extracted from work_order.py. ``StatusService`` wraps a Work Order document
(composition); work_order.py keeps thin delegating stubs so the many external
callers (job cards, sales orders, production plans, patches) keep working.
"""
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form
from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
_QTY_PURPOSES = (
("Manufacture", "produced_qty"),
("Material Transfer for Manufacture", "material_transferred_for_manufacturing"),
("Material Transfer for Manufacture", "additional_transferred_qty"),
)
class StatusService:
def __init__(self, doc):
self.doc = doc
def validate_work_order_against_so(self):
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty)
so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty())
allowance_percentage = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order")
)
if total_qty <= so_qty + (allowance_percentage / 100 * so_qty):
return
frappe.throw(
_("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format(
get_link_to_form("Item", self.doc.production_item),
frappe.bold(so_qty),
frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")),
),
OverProductionError,
)
def _ordered_qty_against_so(self):
wo = frappe.qb.DocType("Work Order")
return (
frappe.qb.from_(wo)
.select(Sum(wo.qty - wo.process_loss_qty))
.where(
(wo.production_item == self.doc.production_item)
& (wo.sales_order == self.doc.sales_order)
& (wo.docstatus == 1)
& (wo.status != "Closed")
& (wo.name != self.doc.name)
)
).run()[0][0]
def _so_item_qty(self):
so_item = frappe.qb.DocType("Sales Order Item")
return (
frappe.qb.from_(so_item)
.select(Sum(so_item.stock_qty))
.where(
(so_item.parent == self.doc.sales_order)
& (so_item.item_code == self.doc.production_item)
& (so_item.docstatus == 1)
)
).run()[0][0]
def _packed_item_qty(self):
packed_item = frappe.qb.DocType("Packed Item")
return (
frappe.qb.from_(packed_item)
.select(Sum(packed_item.qty))
.where(
(packed_item.parent == self.doc.sales_order)
& (packed_item.parenttype == "Sales Order")
& (packed_item.item_code == self.doc.production_item)
& (packed_item.docstatus == 1)
)
).run()[0][0]
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.doc.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)
if status != self.doc.status:
self.doc.db_set("status", status)
self.doc.update_required_items()
return status or self.doc.status
def get_status(self, status=None):
"""Return the status based on stock entries against this work order"""
status = status or self.doc.status
if self.doc.docstatus == 0:
status = "Draft"
elif self.doc.docstatus == 1:
status = self._submitted_status(status)
else:
status = "Cancelled"
if self._is_partial_skip_transfer():
status = "In Process"
if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations):
status = "In Process"
if status == "Not Started" and self.doc.reserve_stock:
status = self._reservation_status(status)
return status
def _submitted_status(self, status):
if status in ["Closed", "Stopped"]:
return status
status = "In Process" if flt(self.doc.material_transferred_for_manufacturing) > 0 else "Not Started"
precision = frappe.get_precision("Work Order", "produced_qty")
total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision)
if flt(total_qty, precision) >= flt(self.doc.qty, precision):
status = "Completed"
return status
def _is_partial_skip_transfer(self):
return bool(
self.doc.skip_transfer
and self.doc.produced_qty
and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty))
)
def _reservation_status(self, status):
for row in self.doc.required_items:
if not row.stock_reserved_qty:
continue
if row.stock_reserved_qty >= row.required_qty:
status = "Stock Reserved"
else:
return "Stock Partially Reserved"
return status
def update_work_order_qty(self):
"""Update Manufactured Qty and Material Transferred for Qty based on Stock Entry"""
if self.doc.track_semi_finished_goods:
return
for purpose, fieldname in _QTY_PURPOSES:
self._update_qty_for_purpose(purpose, fieldname)
if self.doc.production_plan:
self.set_produced_qty_for_sub_assembly_item()
self.update_production_plan_status()
if self.doc.additional_transferred_qty:
self.doc.validate_additional_transferred_qty()
def _update_qty_for_purpose(self, purpose, fieldname):
from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError
if self._skip_transfer_purpose(purpose):
return
qty = self.get_transferred_or_manufactured_qty(purpose, fieldname)
completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty)
if qty > completed_qty:
frappe.throw(
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
_(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name
),
StockOverProductionError,
)
self.doc.db_set(fieldname, qty)
self.set_process_loss_qty()
self._update_produced_qty_in_so()
def _skip_transfer_purpose(self, purpose):
return bool(
purpose == "Material Transfer for Manufacture"
and self.doc.operations
and self.doc.transfer_material_against == "Job Card"
)
def _qty_allowance(self, purpose):
allowance = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)
if not allowance and purpose == "Material Transfer for Manufacture":
allowance = flt(
frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
)
return allowance
def _update_produced_qty_in_so(self):
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
if (
self.doc.sales_order
and self.doc.sales_order_item
and not self.doc.production_plan_sub_assembly_item
):
update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item)
def update_disassembled_qty(self, qty, is_cancel=False):
if is_cancel:
self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty)
else:
if self.doc.docstatus == 1:
self.doc.disassembled_qty += qty
if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty:
frappe.throw(_("Cannot disassemble more than produced quantity."))
self.doc.db_set("disassembled_qty", self.doc.disassembled_qty)
def get_transferred_or_manufactured_qty(self, purpose, fieldname):
parent = frappe.qb.DocType("Stock Entry")
is_additional = cint(fieldname == "additional_transferred_qty")
query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional))
if purpose == "Manufacture":
child = frappe.qb.DocType("Stock Entry Detail")
query = (
query.join(child)
.on(parent.name == child.parent)
.select(Sum(child.transfer_qty))
.where(child.is_finished_item == 1)
)
else:
query = query.select(Sum(parent.fg_completed_qty))
return flt(query.run()[0][0])
def _stock_entry_filter(self, parent, purpose, is_additional):
return (
(parent.work_order == self.doc.name)
& (parent.docstatus == 1)
& (parent.purpose == purpose)
& (parent.is_additional_transfer_entry == is_additional)
)
def set_process_loss_qty(self):
table = frappe.qb.DocType("Stock Entry")
process_loss_qty = (
frappe.qb.from_(table)
.select(Sum(table.process_loss_qty))
.where(
(table.work_order == self.doc.name)
& (table.purpose == "Manufacture")
& (table.docstatus == 1)
)
).run()[0][0]
self.doc.db_set("process_loss_qty", flt(process_loss_qty))
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.doc.production_plan)
produced_qty = 0
if self.doc.production_plan_item:
total_qty = frappe.get_all(
"Work Order",
fields=[{"SUM": "produced_qty", "as": "produced_qty"}],
filters={
"docstatus": 1,
"production_plan": self.doc.production_plan,
"production_plan_item": self.doc.production_plan_item,
},
as_list=1,
)
produced_qty = total_qty[0][0] if total_qty else 0
self.update_status()
production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item)
def update_planned_qty(self):
if self.doc.track_semi_finished_goods:
return
update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict())
if self.doc.material_request:
mr_obj = frappe.get_doc("Material Request", self.doc.material_request)
mr_obj.update_requested_qty([self.doc.material_request_item])
def _planned_qty_dict(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_reserved_qty_for_sub_assembly,
)
qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)}
if self.doc.production_plan_sub_assembly_item and self.doc.production_plan:
qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly(
self.doc.production_item, self.doc.fg_warehouse
)
return qty_dict
def set_produced_qty_for_sub_assembly_item(self):
produced_qty = self._sub_assembly_produced_qty()
frappe.db.set_value(
"Production Plan Sub Assembly Item",
self.doc.production_plan_sub_assembly_item,
"wo_produced_qty",
produced_qty,
)
def _sub_assembly_produced_qty(self):
table = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(table)
.select(Sum(table.produced_qty))
.where(
(table.production_plan == self.doc.production_plan)
& (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item)
& (table.docstatus == 1)
)
).run()
return flt(query[0][0]) if query else 0
def update_ordered_qty(self):
if not (
self.doc.production_plan
and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item)
):
return
qty = self._production_plan_ordered_qty()
if self.doc.production_plan_item:
frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty)
elif self.doc.production_plan_sub_assembly_item:
field = self.doc.production_plan_sub_assembly_item
frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty)
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
doc.set_status()
doc.db_set("status", doc.status)
def _production_plan_ordered_qty(self):
table = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty))
.where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1))
)
if self.doc.production_plan_item:
query = query.where(table.production_plan_item == self.doc.production_plan_item)
elif self.doc.production_plan_sub_assembly_item:
query = query.where(
table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item
)
result = query.run()
return flt(result[0][0]) if result else 0
def update_work_order_qty_in_so(self):
if (
not self.doc.sales_order and not self.doc.sales_order_item
) or self.doc.production_plan_sub_assembly_item:
return
total_bundle_qty = self._total_bundle_qty()
work_order_qty = self._sales_order_work_order_qty()
frappe.db.set_value(
"Sales Order Item",
self.doc.sales_order_item,
"work_order_qty",
flt(work_order_qty / total_bundle_qty, 2),
)
def _sales_order_work_order_qty(self):
wo = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(wo)
.select(Sum(wo.qty))
.where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed"))
)
if self.doc.product_bundle_item:
query = query.where(wo.product_bundle_item == self.doc.product_bundle_item)
else:
query = query.where(wo.production_item == self.doc.production_item)
qty = query.run(as_list=1)
return qty[0][0] if qty and qty[0][0] else 0
def update_work_order_qty_in_combined_so(self):
total_bundle_qty = self._total_bundle_qty()
prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan)
item_reference = frappe.get_value(
"Production Plan Item", self.doc.production_plan_item, "sales_order_item"
)
for plan_reference in prod_plan.prod_plan_references:
if plan_reference.item_reference != item_reference:
continue
qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0
frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty)
def _total_bundle_qty(self):
if not self.doc.product_bundle_item:
return 1
pbi = frappe.qb.DocType("Product Bundle Item")
total_bundle_qty = (
frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item)
).run()[0][0]
# product bundle is 0 (product bundle allows 0 qty for items)
return total_bundle_qty or 1
def update_completed_qty_in_material_request(self):
if self.doc.material_request and self.doc.material_request_item:
frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty(
[self.doc.material_request_item]
)

View File

@@ -0,0 +1,654 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Stock reservation logic for Work Order.
Extracted from work_order.py. ``StockReservationService`` wraps a Work Order
document (composition) and owns the reservation-related behaviour; the
module-level helpers are reused by the controller and by Production Plan.
work_order.py re-exports them to preserve whitelist dotted-paths and imports.
"""
from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, parse_json
from pypika import functions as fn
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
_SCIO_FIELDS = [
"item_code",
"name",
"qty as stock_qty",
"produced_qty as stock_reserved_qty",
"delivery_warehouse as warehouse",
"parent as voucher_no",
"parenttype as voucher_type",
"delivered_qty",
]
_SO_FIELDS = [
"item_code",
"name",
"stock_qty",
"stock_reserved_qty",
"warehouse",
"parent as voucher_no",
"parenttype as voucher_type",
"delivered_qty",
]
_SERIAL_BATCH_FIELDS = [
"`tabSerial and Batch Entry`.`serial_no`",
"`tabSerial and Batch Entry`.`batch_no`",
"`tabSerial and Batch Entry`.`qty`",
"`tabSerial and Batch Bundle`.`warehouse`",
"`tabSerial and Batch Bundle`.`item_code`",
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
]
class StockReservationService:
def __init__(self, doc):
self.doc = doc
def validate_fg_warehouse_for_reservation(self):
if not (
self.doc.reserve_stock
and self.doc.sales_order
and not self.doc.subcontracting_inward_order
and not self.doc.production_plan_sub_assembly_item
):
return
warehouses = frappe.get_all(
"Sales Order Item",
filters={"parent": self.doc.sales_order, "item_code": self.doc.production_item},
pluck="warehouse",
)
if self.doc.fg_warehouse not in warehouses:
self._throw_warehouse_not_allowed(warehouses)
def _throw_warehouse_not_allowed(self, warehouses):
frappe.throw(
_("Warehouse {0} is not allowed for Sales Order {1}, it should be {2}").format(
self.doc.fg_warehouse, self.doc.sales_order, warehouses[0]
),
title=_("Target Warehouse Reservation Error"),
)
def set_reserve_stock(self):
for row in self.doc.required_items:
row.reserve_stock = self.doc.reserve_stock
def enable_auto_reserve_stock(self):
if self.doc.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
self.doc.reserve_stock = 1
def update_stock_reservation(self):
self.doc.set_qty_change()
reserve_stock_for_work_order(self.doc)
self.doc.db_set("status", self.doc.get_status())
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
names = frappe.get_all(
"Stock Reservation Entry",
filters={
"voucher_no": self.doc.name,
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
},
pluck="name",
)
for name in names:
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
def _apply_transferred_qty(self, name, transferred_qty, row_wise_serial_batch):
if transferred_qty < 0:
return transferred_qty
doc = frappe.get_doc("Stock Reservation Entry", name)
qty_to_update, transferred_qty = self._split_transferred_qty(doc, transferred_qty)
if qty_to_update < 0:
return transferred_qty
self._apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch)
return transferred_qty
@staticmethod
def _split_transferred_qty(doc, transferred_qty):
if transferred_qty > flt(doc.reserved_qty - doc.consumed_qty):
qty_to_update = doc.reserved_qty - doc.transferred_qty
return qty_to_update, transferred_qty - qty_to_update
return transferred_qty, 0.0
@staticmethod
def _apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch):
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
if doc.transferred_qty >= doc.reserved_qty:
doc.db_set("status", "Closed", update_modified=False)
doc.update_status()
doc.update_reserved_stock_in_bin()
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
filters = {
"voucher_no": self.doc.name,
"item_code": item.item_code,
"voucher_detail_no": item.name,
"warehouse": wip_warehouse,
"docstatus": 1,
}
if not self.doc.skip_transfer:
filters["from_voucher_no"] = ("is", "set")
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, "Manufacture")
names = frappe.get_all("Stock Reservation Entry", filters=filters, pluck="name", order_by="creation")
for name in names:
consumed_qty = self._apply_consumed_qty(name, consumed_qty, row_wise_serial_batch)
@staticmethod
def _apply_consumed_qty(name, consumed_qty, row_wise_serial_batch):
consumed_qty = max(consumed_qty, 0)
doc = frappe.get_doc("Stock Reservation Entry", name)
qty_to_update = consumed_qty if consumed_qty < doc.reserved_qty else doc.reserved_qty
if qty_to_update >= 0:
doc.db_set("consumed_qty", flt(qty_to_update), update_modified=False)
consumed_qty -= qty_to_update
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
doc.update_status()
doc.update_reserved_stock_in_bin()
return consumed_qty
def validate_reserved_qty(self):
sre_details = get_sre_details(self.doc.name)
for item in self.doc.required_items:
if details := sre_details.get(item.name):
if details.reserved_qty < details.consumed_qty:
frappe.throw(
_("Consumed Qty cannot be greater than Reserved Qty for item {0}").format(
details.consumed_qty, details.reserved_qty, item.item_code
)
)
def set_reserved_qty_for_wip_and_fg(self, stock_entry):
if stock_entry.is_return:
return
stock_entry.reload()
items = self._reservation_items_for(stock_entry)
if not items:
return
reserve_stock_for_work_order(self.doc, list(items.values()), is_transfer=False, notify=True)
def _reservation_items_for(self, stock_entry):
is_finished_good = stock_entry.purpose == "Manufacture" and (
self.doc.sales_order
or self.doc.production_plan_sub_assembly_item
or self.doc.subcontracting_inward_order
or stock_entry.job_card
)
if is_finished_good:
return self.get_finished_goods_for_reservation(stock_entry)
if stock_entry.purpose == "Material Transfer for Manufacture":
return self.get_list_of_materials_for_reservation(stock_entry)
return frappe._dict()
def get_list_of_materials_for_reservation(self, stock_entry):
items = frappe._dict()
voucher_detail_no = {d.item_code: d.name for d in self.doc.required_items}
for row in stock_entry.items:
if row.item_code not in items:
items[row.item_code] = self._material_reservation_row(stock_entry, row, voucher_detail_no)
else:
items[row.item_code]["stock_qty"] += row.transfer_qty
if row.serial_and_batch_bundle:
items[row.item_code]["serial_and_batch_bundles"].append(row.serial_and_batch_bundle)
return items
def _material_reservation_row(self, stock_entry, row, voucher_detail_no):
return frappe._dict(
{
"voucher_no": self.doc.name,
"voucher_type": self.doc.doctype,
"voucher_detail_no": voucher_detail_no.get(row.item_code),
"item_code": row.item_code,
"warehouse": row.t_warehouse,
"stock_qty": row.transfer_qty,
"from_voucher_no": stock_entry.name,
"from_voucher_type": stock_entry.doctype,
"from_voucher_detail_no": row.name,
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
}
)
def get_finished_goods_for_reservation(self, stock_entry):
item_details = self._finished_goods_item_details(stock_entry)
if item_details is None:
return
items = frappe._dict()
for item in item_details:
self._reserve_finished_good(items, item, stock_entry)
return items
def _finished_goods_item_details(self, stock_entry):
if self.doc.production_plan_sub_assembly_item:
# Reserve the sub-assembly item for the final product for the work order.
return self.get_wo_details()
if self.doc.subcontracting_inward_order:
return self.get_scio_details()
if stock_entry.job_card:
# Reserve the final product for the job card.
finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good")
if finished_good == self.doc.production_item:
return None
return self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
# Reserve the final product for the sales order.
return self.get_so_details()
def _reserve_finished_good(self, items, item, stock_entry):
qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty)
if qty_to_reserve <= 0:
return
warehouse = self._reservation_warehouse(item)
for row in stock_entry.items:
if not self._is_reservable_fg_row(row, item, warehouse):
continue
reserved_qty = min(qty_to_reserve, row.transfer_qty)
qty_to_reserve = qty_to_reserve - row.transfer_qty if qty_to_reserve > row.transfer_qty else 0
if row.item_code not in items:
items[row.item_code] = self._fg_reservation_row(item, row, reserved_qty, stock_entry)
else:
items[row.item_code]["stock_qty"] += reserved_qty
@staticmethod
def _is_reservable_fg_row(row, item, warehouse):
return bool(
row.t_warehouse
and row.is_finished_item
and row.t_warehouse == warehouse
and row.item_code == item.item_code
)
@staticmethod
def _reservation_warehouse(item):
if (
item.get("parenttype") == "Work Order"
and item.get("skip_transfer")
and item.get("from_wip_warehouse")
):
return item.wip_warehouse
return item.warehouse
@staticmethod
def _fg_reservation_row(item, row, reserved_qty, stock_entry):
return frappe._dict(
{
"voucher_no": item.voucher_no,
"voucher_type": item.voucher_type,
"voucher_detail_no": item.name,
"item_code": row.item_code,
"warehouse": row.t_warehouse,
"stock_qty": reserved_qty,
"from_voucher_no": stock_entry.name,
"from_voucher_type": stock_entry.doctype,
"from_voucher_detail_no": row.name,
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
}
)
def get_items_to_reserve_for_job_card(self, stock_entry, finished_good):
for row in stock_entry.items:
if row.item_code == finished_good:
return self._job_card_reservation_details(stock_entry, row, finished_good)
return []
def _job_card_reservation_details(self, stock_entry, row, finished_good):
name = frappe.db.get_value(
"Work Order Item", {"item_code": finished_good, "parent": self.doc.name}, "name"
)
pending_qty = row.qty - self._reserved_qty_for_job_card(finished_good, name, row.t_warehouse)
if pending_qty <= 0:
return []
return [
frappe._dict(
{
"item_code": row.item_code,
"stock_qty": pending_qty,
"stock_reserved_qty": 0,
"warehouse": row.t_warehouse,
"voucher_no": stock_entry.work_order,
"voucher_type": "Work Order",
"name": name,
"delivered_qty": 0,
}
)
]
def _reserved_qty_for_job_card(self, finished_good, name, warehouse):
sres = frappe.get_all(
"Stock Reservation Entry",
fields=["reserved_qty"],
filters={
"voucher_no": self.doc.name,
"item_code": finished_good,
"voucher_detail_no": name,
"warehouse": warehouse,
"docstatus": 1,
"status": "Reserved",
},
)
return sum(d.reserved_qty for d in sres)
def get_wo_details(self):
wo = frappe.qb.DocType("Work Order")
item = frappe.qb.DocType("Work Order Item")
query = (
frappe.qb.from_(wo)
.inner_join(item)
.on(wo.name == item.parent)
.select(
item.name,
item.required_qty.as_("stock_qty"),
item.transferred_qty.as_("delivered_qty"),
item.stock_reserved_qty,
item.source_warehouse.as_("warehouse"),
wo.wip_warehouse,
wo.skip_transfer,
wo.from_wip_warehouse,
item.parenttype,
item.item_code,
item.parent.as_("voucher_no"),
item.parenttype.as_("voucher_type"),
)
.where(self._wo_details_filter(wo, item))
)
return query.run(as_dict=1)
def _wo_details_filter(self, wo, item):
return (
(item.item_code == self.doc.production_item)
& (wo.docstatus == 1)
& (wo.production_plan == self.doc.production_plan)
& (IfNull(wo.production_plan_sub_assembly_item, "") != self.doc.production_plan_sub_assembly_item)
)
def get_scio_details(self):
return frappe.get_all(
"Subcontracting Inward Order Item",
filters={"name": self.doc.subcontracting_inward_order_item, "docstatus": 1},
fields=_SCIO_FIELDS,
)
def get_so_details(self):
return frappe.get_all(
"Sales Order Item",
filters={"parent": self.doc.sales_order, "item_code": self.doc.production_item, "docstatus": 1},
fields=_SO_FIELDS,
)
def get_voucher_details(self, stock_entry):
if stock_entry.purpose == "Manufacture" and self.doc.sales_order:
return frappe._dict({self.doc.production_item: self._so_voucher_detail()})
return frappe._dict({d.item_code: d.name for d in self.doc.required_items})
def _so_voucher_detail(self):
return frappe.db.get_value(
"Sales Order Item",
{
"parent": self.doc.sales_order,
"item_code": self.doc.production_item,
"docstatus": 1,
"stock_reserved_qty": 0,
},
["name", "stock_qty", "stock_reserved_qty"],
as_dict=1,
)
def cancel_reserved_qty_for_wip_and_fg(self, ste_doc):
for row in ste_doc.items:
sre_list = frappe.get_all(
"Stock Reservation Entry",
filters={"from_voucher_no": ste_doc.name, "from_voucher_detail_no": row.name, "docstatus": 1},
pluck="name",
)
if sre_list:
unreserve_stock_for_work_order(self.doc, sre_list)
@frappe.whitelist()
def make_stock_reservation_entries(
doc: str | Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False
):
"""Whitelisted entry point: verify Work Order write access, then reserve stock."""
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name"))
frappe.has_permission("Work Order", "write", doc=doc, throw=True)
reserve_stock_for_work_order(doc, items=items, is_transfer=is_transfer, notify=notify)
def reserve_stock_for_work_order(
doc: Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False
):
"""Reserve (or transfer/cancel) stock for a Work Order. Internal: no permission check.
Called both by the whitelisted entry point above and from the Work Order /
Stock Entry lifecycle, where the triggering user may legitimately lack direct
Work Order write access.
"""
is_transfer = cint(is_transfer)
if items and isinstance(items, str):
items = parse_json(items)
sre = StockReservation(doc, items=items)
if doc.docstatus == 2 or doc.status == "Closed":
sre.cancel_stock_reservation_entries()
elif doc.docstatus == 1:
_reserve_or_transfer(sre, doc, is_transfer)
doc.reload()
doc.db_set("status", doc.get_status())
def _reserve_or_transfer(sre, doc, is_transfer):
if doc.production_plan and is_transfer:
sre.transfer_reservation_entries_to(
doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order"
)
elif doc.subcontracting_inward_order and is_transfer:
sre.transfer_reservation_entries_to(
doc.subcontracting_inward_order,
from_doctype="Subcontracting Inward Order",
to_doctype="Work Order",
against_fg_item=doc.subcontracting_inward_order_item,
qty_change=doc.qty_change,
)
elif sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
@frappe.whitelist()
def cancel_stock_reservation_entries(doc: str | dict, sre_list: str | list):
"""Whitelisted entry point: verify Work Order write access, then cancel reservations."""
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name"))
frappe.has_permission("Work Order", "write", doc=doc, throw=True)
unreserve_stock_for_work_order(doc, sre_list)
def unreserve_stock_for_work_order(doc: Document, sre_list: str | list):
"""Cancel stock reservation entries for a Work Order. Internal: no permission check."""
sre = StockReservation(doc)
sre.cancel_stock_reservation_entries(sre_list)
doc.reload()
doc.db_set("status", doc.get_status())
def get_sre_details(work_order):
sre_details = frappe._dict()
data = frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": work_order, "docstatus": 1},
fields=[
"item_code",
"warehouse",
"reserved_qty",
"transferred_qty",
"consumed_qty",
"voucher_detail_no",
],
)
for row in data:
_accumulate_sre_details(sre_details, row)
return sre_details
def _accumulate_sre_details(sre_details, row):
existing = sre_details.get(row.voucher_detail_no)
if not existing:
sre_details[row.voucher_detail_no] = row
return
existing.reserved_qty += row.reserved_qty
existing.transferred_qty += row.transferred_qty
existing.consumed_qty += row.consumed_qty
def get_consumed_qty(work_order, item_code):
stock_entry = frappe.qb.DocType("Stock Entry")
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
result = (
frappe.qb.from_(stock_entry)
.inner_join(stock_entry_detail)
.on(stock_entry_detail.parent == stock_entry.name)
.select(fn.Sum(stock_entry_detail.transfer_qty).as_("qty"))
.where(_consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code))
).run()
return flt(result[0][0]) if result else 0
def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code):
return (
(stock_entry.work_order == work_order)
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
& (stock_entry.docstatus == 1)
& (stock_entry_detail.s_warehouse.isnotnull())
& ((stock_entry_detail.item_code == item_code) | (stock_entry_detail.original_item == item_code))
)
def get_reserved_qty_for_production(
item_code: str,
warehouse: str,
non_completed_production_plans: list | None = None,
check_production_plan: bool = False,
) -> float:
"""Get total reserved quantity for any item in specified warehouse"""
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
qty_field = wo_item.required_qty if check_production_plan else _production_reserved_qty_field(wo, wo_item)
query = (
frappe.qb.from_(wo)
.from_(wo_item)
.select(Sum(qty_field))
.where(
(wo_item.item_code == item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse)
)
)
query = _apply_production_plan_filter(
query, wo, wo_item, check_production_plan, non_completed_production_plans
)
return query.run()[0][0] or 0.0
def _production_reserved_qty_field(wo, wo_item):
qty_field = Case()
qty_field = qty_field.when(
(wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty), 0.0
)
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
return qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
def _apply_production_plan_filter(query, wo, wo_item, check_production_plan, non_completed_production_plans):
if check_production_plan:
query = query.where(wo.production_plan.isnotnull())
else:
query = query.where(
(wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
if non_completed_production_plans:
query = query.where(wo.production_plan.isin(non_completed_production_plans))
return query
def get_row_wise_serial_batch(work_order, purpose=None):
purpose = purpose or "Material Transfer for Manufacture"
stock_entries = frappe.get_all(
"Stock Entry",
filters={"work_order": work_order, "purpose": purpose, "docstatus": 1},
pluck="name",
)
row_wise_serial_batch = {}
for entry in _serial_batch_entries(stock_entries):
_accumulate_serial_batch(row_wise_serial_batch, entry)
return row_wise_serial_batch
def _serial_batch_entries(stock_entries):
return frappe.get_all(
"Serial and Batch Bundle",
fields=_SERIAL_BATCH_FIELDS,
filters=[
["Serial and Batch Bundle", "voucher_type", "=", "Stock Entry"],
["Serial and Batch Bundle", "voucher_no", "in", stock_entries],
["Serial and Batch Bundle", "voucher_detail_no", "is", "set"],
["Serial and Batch Bundle", "docstatus", "<", 2],
["Serial and Batch Bundle", "is_cancelled", "=", 0],
["Serial and Batch Entry", "qty", "<", 0],
],
)
def _accumulate_serial_batch(row_wise_serial_batch, entry):
key = (entry.item_code, entry.warehouse)
details = row_wise_serial_batch.setdefault(
key, frappe._dict({"serial_nos": [], "batch_nos": defaultdict(float)})
)
if entry.serial_no:
details.serial_nos.append(entry.serial_no)
if entry.batch_no:
details.batch_nos[entry.batch_no] += abs(entry.qty)

File diff suppressed because it is too large Load Diff

View File

@@ -1045,6 +1045,10 @@ class StockEntry(StockController, SubcontractingInwardController):
return getattr(self, "_wo_doc", None)
def make_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
)
if self.is_stock_reserve_for_work_order():
pro_doc = frappe.get_doc("Work Order", self.work_order)
if (
@@ -1056,7 +1060,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
pro_doc.set_reserved_qty_for_wip_and_fg(self)
StockReservationService(pro_doc).set_reserved_qty_for_wip_and_fg(self)
def reserve_stock_for_subcontracting(self):
if self.purpose == "Send to Subcontractor" and frappe.get_value(
@@ -1083,6 +1087,10 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def cancel_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
)
if self.is_stock_reserve_for_work_order():
pro_doc = frappe.get_doc("Work Order", self.work_order)
if (
@@ -1092,7 +1100,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
pro_doc.cancel_reserved_qty_for_wip_and_fg(self)
StockReservationService(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
def is_stock_reserve_for_work_order(self):
if (