diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index f5e943823cf..723e7eba560 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -4,16 +4,13 @@
import functools
import re
from collections import deque
-from operator import itemgetter
import frappe
from frappe import _, bold
-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 Count, IfNull, Sum
-from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json, today
+from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
from frappe.website.website_generator import WebsiteGenerator
import erpnext
@@ -24,6 +21,31 @@ from erpnext.stock.get_item_details import ItemDetailsCtx, get_conversion_factor
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
+# Backward-compatible re-exports: these were moved to mapper.py / services/.
+# Re-importing here preserves whitelist dotted-paths and external imports.
+from erpnext.manufacturing.doctype.bom.mapper import (
+ get_bom_diff,
+ get_children,
+ item_query,
+ make_variant_bom,
+)
+from erpnext.manufacturing.doctype.bom.services.costing import (
+ BOMCostingService,
+)
+from erpnext.manufacturing.doctype.bom.services.exploded_items import (
+ BOMExplodedItemsService,
+)
+from erpnext.manufacturing.doctype.bom.services.operations_cost import (
+ add_additional_cost,
+ add_non_stock_items_cost,
+ add_operating_cost_component_wise,
+ add_operations_cost,
+ get_component_account,
+ get_op_cost_from_sub_assemblies,
+ get_secondary_items_from_sub_assemblies,
+)
+
+
class BOMRecursionError(frappe.ValidationError):
pass
@@ -183,28 +205,13 @@ class BOM(WebsiteGenerator):
def autoname(self):
# ignore amended documents while calculating current index
-
search_key = f"{self.doctype}-{self.item}%"
existing_boms = frappe.get_all(
"BOM", filters={"name": search_key, "amended_from": ["is", "not set"]}, pluck="name"
)
index = self.get_index_for_bom(existing_boms)
-
- prefix = self.doctype
- suffix = "%.3i" % index # convert index to string (1 -> "001")
- bom_name = f"{prefix}-{self.item}-{suffix}"
-
- if len(bom_name) <= 140:
- name = bom_name
- else:
- # since max characters for name is 140, remove enough characters from the
- # item name to fit the prefix, suffix and the separators
- truncated_length = 140 - (len(prefix) + len(suffix) + 2)
- truncated_item_name = self.item[:truncated_length]
- # if a partial word is found after truncate, remove the extra characters
- truncated_item_name = truncated_item_name.rsplit(" ", 1)[0]
- name = f"{prefix}-{truncated_item_name}-{suffix}"
+ name = self._build_bom_name(index)
if frappe.db.exists("BOM", name):
existing_boms = frappe.get_all(
@@ -212,11 +219,26 @@ class BOM(WebsiteGenerator):
)
index = self.get_index_for_bom(existing_boms)
- suffix = "%.3i" % index
- name = f"{prefix}-{self.item}-{suffix}"
+ name = f"{self.doctype}-{self.item}-{'%.3i' % index}"
self.name = name
+ def _build_bom_name(self, index):
+ prefix = self.doctype
+ suffix = "%.3i" % index # convert index to string (1 -> "001")
+ bom_name = f"{prefix}-{self.item}-{suffix}"
+
+ if len(bom_name) <= 140:
+ return bom_name
+
+ # since max characters for name is 140, remove enough characters from the
+ # item name to fit the prefix, suffix and the separators
+ truncated_length = 140 - (len(prefix) + len(suffix) + 2)
+ truncated_item_name = self.item[:truncated_length]
+ # if a partial word is found after truncate, remove the extra characters
+ truncated_item_name = truncated_item_name.rsplit(" ", 1)[0]
+ return f"{prefix}-{truncated_item_name}-{suffix}"
+
def get_index_for_bom(self, existing_boms):
index = 1
if existing_boms:
@@ -279,6 +301,14 @@ class BOM(WebsiteGenerator):
if not self.company:
frappe.throw(_("Please select a Company first."), title=_("Mandatory"))
+ self._validate_setup()
+ self._validate_materials_and_cost()
+ self._validate_uoms_and_goods()
+
+ if self.docstatus == 1:
+ self.validate_raw_materials_of_operation()
+
+ def _validate_setup(self):
self.clear_operations()
self.clear_inspection()
self.validate_main_item()
@@ -287,6 +317,8 @@ class BOM(WebsiteGenerator):
self.set_conversion_rate()
self.set_plc_conversion_rate()
self.validate_uom_is_interger()
+
+ def _validate_materials_and_cost(self):
self.set_bom_material_details()
self.set_secondary_items_details()
self.validate_materials()
@@ -297,6 +329,8 @@ class BOM(WebsiteGenerator):
self.update_exploded_items(save=False)
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
+
+ def _validate_uoms_and_goods(self):
self.set_process_loss_qty()
self.validate_uoms()
self.set_default_uom()
@@ -305,9 +339,6 @@ class BOM(WebsiteGenerator):
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
- if self.docstatus == 1:
- self.validate_raw_materials_of_operation()
-
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods or not self.operations:
return
@@ -456,31 +487,35 @@ class BOM(WebsiteGenerator):
@frappe.whitelist()
def get_routing(self):
- if self.routing:
- self.set("operations", [])
- fields = [
- "sequence_id",
- "operation",
- "workstation",
- "workstation_type",
- "description",
- "time_in_mins",
- "batch_size",
- "operating_cost",
- "idx",
- "hour_rate",
- "set_cost_based_on_bom_qty",
- "fixed_time",
- ]
+ if not self.routing:
+ return
- for row in frappe.get_all(
- "BOM Operation",
- fields=fields,
- filters={"parenttype": "Routing", "parent": self.routing},
- order_by="sequence_id, idx",
- ):
- child = self.append("operations", row)
- child.hour_rate = flt(row.hour_rate / self.conversion_rate, child.precision("hour_rate"))
+ self.set("operations", [])
+ for row in frappe.get_all(
+ "BOM Operation",
+ fields=self._get_routing_fields(),
+ filters={"parenttype": "Routing", "parent": self.routing},
+ order_by="sequence_id, idx",
+ ):
+ child = self.append("operations", row)
+ child.hour_rate = flt(row.hour_rate / self.conversion_rate, child.precision("hour_rate"))
+
+ @staticmethod
+ def _get_routing_fields():
+ return [
+ "sequence_id",
+ "operation",
+ "workstation",
+ "workstation_type",
+ "description",
+ "time_in_mins",
+ "batch_size",
+ "operating_cost",
+ "idx",
+ "hour_rate",
+ "set_cost_based_on_bom_qty",
+ "fixed_time",
+ ]
def set_bom_material_details(self):
for item in self.get("items"):
@@ -527,14 +562,7 @@ class BOM(WebsiteGenerator):
@frappe.whitelist()
def get_bom_material_detail(self, args: dict | str | None = None):
"""Get raw material details like uom, desc and rate"""
- if not args:
- args = frappe.form_dict.get("args")
-
- if isinstance(args, str):
- import json
-
- args = json.loads(args)
-
+ args = self._normalize_material_args(args)
item = self.get_item_det(args["item_code"])
args["bom_no"] = args.get("bom_no") or item and cstr(item["default_bom"]) or ""
@@ -547,6 +575,21 @@ class BOM(WebsiteGenerator):
args.update(item)
rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0
+ return self._build_rm_detail(args, item, rate)
+
+ @staticmethod
+ def _normalize_material_args(kwargs):
+ if not kwargs:
+ kwargs = frappe.form_dict.get("args")
+
+ if isinstance(kwargs, str):
+ import json
+
+ kwargs = json.loads(kwargs)
+
+ return kwargs
+
+ def _build_rm_detail(self, args, item, rate):
ret_item = {
"item_name": item and args["item_name"] or "",
"description": item and args["description"] or "",
@@ -574,118 +617,13 @@ class BOM(WebsiteGenerator):
return ret_item
- def validate_bom_currency(self, item):
- if item.get("bom_no") and frappe.db.get_value("BOM", item.get("bom_no"), "currency") != self.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.currency
- )
- )
-
- def get_rm_rate(self, arg, notify=True):
- """Get raw material rate as per selected method, if bom exists takes bom cost"""
- rate = 0
- if not self.rm_cost_as_per:
- self.rm_cost_as_per = "Valuation Rate"
-
- if arg:
- # Customer Provided parts and Supplier sourced parts will have zero rate
- if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
- "sourced_by_supplier"
- ):
- if arg.get("bom_no") and (
- self.set_rate_of_sub_assembly_item_based_on_bom or arg.get("is_phantom_item")
- ):
- rate = flt(self.get_bom_unitcost(arg["bom_no"])) * (arg.get("conversion_factor") or 1)
- else:
- rate = get_bom_item_rate(arg, self)
-
- if not rate:
- if self.rm_cost_as_per == "Price List":
- frappe.msgprint(
- _("Price not found for item {0} in price list {1}").format(
- arg["item_code"], self.buying_price_list
- ),
- alert=True,
- )
- elif notify:
- frappe.msgprint(
- _("{0} not found for item {1}").format(self.rm_cost_as_per, arg["item_code"]),
- alert=True,
- )
- return flt(rate) * flt(self.plc_conversion_rate or 1) / (self.conversion_rate or 1)
-
- @frappe.whitelist()
- def update_cost(
- self,
- update_parent: bool = True,
- from_child_bom: bool = False,
- update_hour_rate: bool = True,
- save: bool = True,
- ):
- if self.docstatus == 2:
- return
-
- self.flags.cost_updated = False
- existing_bom_cost = self.total_cost
-
- if self.docstatus == 1:
- self.flags.ignore_validate_update_after_submit = True
-
- self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
-
- if save:
- self.db_update()
-
- # update parent BOMs
- if self.total_cost != existing_bom_cost and update_parent:
- parent_boms = frappe.db.sql_list(
- """select distinct parent from `tabBOM Item`
- where bom_no = %s and docstatus=1 and parenttype='BOM'""",
- self.name,
- )
-
- for bom in parent_boms:
- frappe.get_doc("BOM", bom).update_cost(from_child_bom=True)
-
- if not from_child_bom:
- msg = "Cost Updated"
- if not self.flags.cost_updated:
- msg = "No changes in cost found"
-
- frappe.msgprint(_(msg), alert=True)
-
- def update_parent_cost(self):
- if self.total_cost:
- cost = self.total_cost / self.quantity
-
- frappe.db.sql(
- """update `tabBOM Item` set rate=%s, amount=stock_qty*%s
- where bom_no = %s and docstatus < 2 and parenttype='BOM'""",
- (cost, cost, self.name),
- )
-
- def get_bom_unitcost(self, bom_no):
- bom = frappe.db.sql(
- """select name, base_total_cost/quantity as unit_cost from `tabBOM`
- where is_active = 1 and name = %s""",
- bom_no,
- as_dict=1,
- )
- return bom and bom[0]["unit_cost"] or 0
-
def manage_default_bom(self):
"""Uncheck others if current one is selected as default or
check the current one as default if it the only bom for the selected item,
update default bom in item master
"""
if self.is_default and self.is_active:
- from frappe.model.utils import set_default
-
- set_default(self, "item")
- item = frappe.get_doc("Item", self.item)
- if item.default_bom != self.name:
- frappe.db.set_value("Item", self.item, "default_bom", self.name)
+ self._set_as_default_bom()
elif (
not frappe.db.exists(dict(doctype="BOM", docstatus=1, item=self.item, is_default=1))
and self.is_active
@@ -693,10 +631,21 @@ class BOM(WebsiteGenerator):
self.db_set("is_default", 1)
frappe.db.set_value("Item", self.item, "default_bom", self.name)
else:
- self.db_set("is_default", 0)
- item = frappe.get_doc("Item", self.item)
- if item.default_bom == self.name:
- frappe.db.set_value("Item", self.item, "default_bom", None)
+ self._unset_default_bom()
+
+ def _set_as_default_bom(self):
+ from frappe.model.utils import set_default
+
+ set_default(self, "item")
+ item = frappe.get_doc("Item", self.item)
+ if item.default_bom != self.name:
+ frappe.db.set_value("Item", self.item, "default_bom", self.name)
+
+ def _unset_default_bom(self):
+ self.db_set("is_default", 0)
+ item = frappe.get_doc("Item", self.item)
+ if item.default_bom == self.name:
+ frappe.db.set_value("Item", self.item, "default_bom", None)
def clear_operations(self):
if not self.with_operations:
@@ -792,20 +741,6 @@ class BOM(WebsiteGenerator):
def check_recursion(self, bom_list=None):
"""Check whether recursion occurs in any bom"""
-
- def _throw_error(bom_name, production_item=None):
- msg = _("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name)
- if production_item and bom_name != self.name:
- msg += "
"
- msg += _(
- "Note: If you want to use the finished good {0} as a raw material, then enable the 'Do Not Explode' checkbox in the Items table against the same raw material."
- ).format(bold(production_item))
-
- frappe.throw(
- msg,
- exc=BOMRecursionError,
- )
-
bom_list = self.traverse_tree()
child_items = frappe.get_all(
"BOM Item",
@@ -814,15 +749,31 @@ class BOM(WebsiteGenerator):
)
for item in child_items:
- if self.name == item.bom_no:
- _throw_error(self.name)
- if self.item == item.item_code and item.bom_no:
- # Same item but with different BOM should not be allowed.
- # Same item can appear recursively once as long as it doesn't have BOM.
- _throw_error(item.bom_no, self.item)
+ self._check_item_recursion(item)
if self.name in {d.bom_no for d in self.items}:
- _throw_error(self.name)
+ self._throw_recursion_error(self.name)
+
+ def _check_item_recursion(self, item):
+ if self.name == item.bom_no:
+ self._throw_recursion_error(self.name)
+ if self.item == item.item_code and item.bom_no:
+ # Same item but with different BOM should not be allowed.
+ # Same item can appear recursively once as long as it doesn't have BOM.
+ self._throw_recursion_error(item.bom_no, self.item)
+
+ def _throw_recursion_error(self, bom_name, production_item=None):
+ msg = _("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name)
+ if production_item and bom_name != self.name:
+ msg += "
"
+ msg += _(
+ "Note: If you want to use the finished good {0} as a raw material, then enable the 'Do Not Explode' checkbox in the Items table against the same raw material."
+ ).format(bold(production_item))
+
+ frappe.throw(
+ msg,
+ exc=BOMRecursionError,
+ )
def set_materials_based_on_operation_bom(self):
if not self.track_semi_finished_goods:
@@ -838,32 +789,33 @@ class BOM(WebsiteGenerator):
items = parse_json(items)
for row in items:
- row = parse_json(row)
-
- row.update(get_item_details(row.get("item_code")))
- row.operation_row_id = operation_row_id
-
- item_row = None
- if row.name:
- item_row = self.get_item_data(row.name)
-
- if item_row:
- item_row.update(
- {
- "item_code": row.get("item_code"),
- "qty": row.get("qty"),
- }
- )
- else:
- row.idx = None
- row.name = None
- row.do_not_explode = 1
- row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code)
-
- self.append("items", row)
+ self._add_raw_material_row(operation_row_id, row)
self.save()
+ def _add_raw_material_row(self, operation_row_id, row):
+ row = parse_json(row)
+
+ row.update(get_item_details(row.get("item_code")))
+ row.operation_row_id = operation_row_id
+
+ item_row = self.get_item_data(row.name) if row.name else None
+
+ if item_row:
+ item_row.update(
+ {
+ "item_code": row.get("item_code"),
+ "qty": row.get("qty"),
+ }
+ )
+ else:
+ row.idx = None
+ row.name = None
+ row.do_not_explode = 1
+ row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code)
+
+ self.append("items", row)
+
def is_sub_assembly_item(self, item_code):
if not self.operations:
return False
@@ -898,26 +850,18 @@ class BOM(WebsiteGenerator):
bom_items = get_bom_items(bom_no, self.company, qty=qty, fetch_exploded=0)
for row in bom_items:
- row.uom = row.stock_uom
- row.operation_row_id = operation_row_id
- row.idx = None
- row.do_not_explode = 1
- row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code)
+ self._append_bom_material_row(row, operation_row_id)
- self.append("items", row)
+ def _append_bom_material_row(self, row, operation_row_id):
+ row.uom = row.stock_uom
+ row.operation_row_id = operation_row_id
+ row.idx = None
+ row.do_not_explode = 1
+ row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code)
+
+ self.append("items", row)
def traverse_tree(self, bom_list=None):
- def _get_children(bom_no):
- children = frappe.cache().hget("bom_children", bom_no)
- if children is None:
- children = frappe.db.sql_list(
- """SELECT `bom_no` FROM `tabBOM Item`
- WHERE `parent`=%s AND `bom_no`!='' AND `parenttype`='BOM'""",
- bom_no,
- )
- frappe.cache().hset("bom_children", bom_no, children)
- return children
-
count = 0
if not bom_list:
bom_list = []
@@ -926,295 +870,34 @@ class BOM(WebsiteGenerator):
bom_list.append(self.name)
while count < len(bom_list):
- for child_bom in _get_children(bom_list[count]):
+ for child_bom in _get_bom_children(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1
bom_list.reverse()
return bom_list
- 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.total_cost
-
- self.total_cost = self.operating_cost + self.raw_material_cost - self.secondary_items_cost
- self.base_total_cost = (
- self.base_operating_cost + self.base_raw_material_cost - self.base_secondary_items_cost
- )
-
- if self.total_cost != old_cost:
- self.flags.cost_updated = True
-
- def calculate_op_cost(self, update_hour_rate=False):
- """Update workstation rate and calculates totals"""
- self.operating_cost = 0
- self.base_operating_cost = 0
- if self.get("with_operations"):
- for d in self.get("operations"):
- 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.quantity)
- base_operating_cost = flt(d.base_cost_per_unit) * flt(self.quantity)
-
- self.operating_cost += flt(operating_cost)
- self.base_operating_cost += flt(base_operating_cost)
-
- elif self.get("fg_based_operating_cost"):
- total_operating_cost = flt(self.get("quantity")) * flt(
- self.get("operating_cost_per_bom_quantity")
- )
- self.operating_cost = total_operating_cost
- self.base_operating_cost = flt(total_operating_cost * self.conversion_rate, 2)
-
- def update_rate_and_time(self, row, update_hour_rate=False):
- if not row.hour_rate or update_hour_rate:
- 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.conversion_rate) if self.conversion_rate and hour_rate else hour_rate
- )
-
- if row.hour_rate:
- row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate)
-
- if row.time_in_mins:
- row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
- row.base_operating_cost = flt(row.operating_cost) * flt(self.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)
-
- if update_hour_rate:
- row.db_update()
-
- 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.get("items"):
- old_rate = d.rate
- if not self.bom_creator and (d.is_stock_item or d.is_phantom_item):
- d.rate = self.get_rm_rate(
- {
- "company": self.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,
- },
- notify=False,
- )
-
- d.base_rate = flt(d.rate) * flt(self.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.conversion_rate)
- d.qty_consumed_per_unit = flt(d.stock_qty, d.precision("stock_qty")) / flt(
- self.quantity, self.precision("quantity")
- )
-
- total_rm_cost += d.amount
- base_total_rm_cost += d.base_amount
- if save and (old_rate != d.rate):
- d.db_update()
-
- self.raw_material_cost = total_rm_cost
- self.base_raw_material_cost = base_total_rm_cost
-
- 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.precision("raw_material_cost")
-
- for d in self.get("secondary_items"):
- if not d.is_legacy:
- d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
- d.base_cost = flt(d.cost * self.conversion_rate, precision)
-
- total_sm_cost += d.cost
- base_total_sm_cost += d.base_cost
- if save:
- d.db_update()
-
- self.secondary_items_cost = total_sm_cost
- self.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.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.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
-
- 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.cur_exploded_items = {}
- for d in self.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(
- 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 company_currency(self):
return erpnext.get_company_currency(self.company)
- def add_to_cur_exploded_items(self, args):
- key = args.item_code
- if args.operation:
- key = (args.item_code, args.operation)
-
- if self.cur_exploded_items.get(key):
- self.cur_exploded_items[key]["stock_qty"] += args.stock_qty
- else:
- self.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"""
- # Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
- child_fb_items = frappe.db.sql(
- """
- 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,
- bom_item.stock_qty / ifnull(bom.quantity, 1) AS qty_consumed_per_unit
- FROM `tabBOM Explosion Item` bom_item, `tabBOM` bom
- WHERE
- bom_item.parent = bom.name
- AND bom.name = %s
- AND bom.docstatus = 1
- """,
- bom_no,
- as_dict=1,
- )
-
- for d in child_fb_items:
- self.add_to_cur_exploded_items(
- 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.set("exploded_items", [])
-
- if save:
- frappe.db.sql("""delete from `tabBOM Explosion Item` where parent=%s""", self.name)
-
- for d in sorted(self.cur_exploded_items, key=itemgetter(0)):
- ch = self.append("exploded_items", {})
- for i in self.cur_exploded_items[d].keys():
- ch.set(i, self.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.quantity)
- ch.docstatus = self.docstatus
-
- if save:
- ch.db_insert()
-
def validate_bom_links(self):
if not self.is_active:
- act_pbom = frappe.db.sql(
- """select distinct bom_item.parent from `tabBOM Item` bom_item
- where bom_item.bom_no = %s and bom_item.docstatus = 1 and bom_item.parenttype='BOM'
- and exists (select * from `tabBOM` where name = bom_item.parent
- and docstatus = 1 and is_active = 1)""",
- self.name,
- )
+ bom_item = frappe.qb.DocType("BOM Item")
+ bom = frappe.qb.DocType("BOM")
+ act_pbom = (
+ frappe.qb.from_(bom_item)
+ .join(bom)
+ .on(bom.name == bom_item.parent)
+ .select(bom_item.parent)
+ .distinct()
+ .where(
+ (bom_item.bom_no == self.name)
+ & (bom_item.docstatus == 1)
+ & (bom_item.parenttype == "BOM")
+ & (bom.docstatus == 1)
+ & (bom.is_active == 1)
+ )
+ ).run()
if act_pbom and act_pbom[0][0]:
frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs"))
@@ -1238,23 +921,26 @@ class BOM(WebsiteGenerator):
if self.with_operations:
for d in self.operations:
- if not d.description:
- d.description = frappe.db.get_value("Operation", d.operation, "description")
- if not d.batch_size or d.batch_size <= 0:
- d.batch_size = 1
+ self._validate_operation_row(d)
- if not d.workstation and not d.workstation_type:
- frappe.throw(
- _(
- "Row {0}: Workstation or Workstation Type is mandatory for an operation {1}"
- ).format(d.idx, d.operation)
- )
- if not d.time_in_mins or d.time_in_mins <= 0:
- frappe.throw(
- _("Row {0}: Operation time should be greater than 0 for operation {1}").format(
- d.idx, d.operation
- )
- )
+ def _validate_operation_row(self, d):
+ if not d.description:
+ d.description = frappe.db.get_value("Operation", d.operation, "description")
+ if not d.batch_size or d.batch_size <= 0:
+ d.batch_size = 1
+
+ if not d.workstation and not d.workstation_type:
+ frappe.throw(
+ _("Row {0}: Workstation or Workstation Type is mandatory for an operation {1}").format(
+ d.idx, d.operation
+ )
+ )
+ if not d.time_in_mins or d.time_in_mins <= 0:
+ frappe.throw(
+ _("Row {0}: Operation time should be greater than 0 for operation {1}").format(
+ d.idx, d.operation
+ )
+ )
def get_tree_representation(self) -> BOMTree:
"""Get a complete tree representation preserving order of child items."""
@@ -1289,6 +975,82 @@ class BOM(WebsiteGenerator):
d.get("secondary_item_type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items")
)
+ def validate_bom_currency(self, *args, **kwargs):
+ return BOMCostingService(self).validate_bom_currency(*args, **kwargs)
+
+ def get_rm_rate(self, *args, **kwargs):
+ return BOMCostingService(self).get_rm_rate(*args, **kwargs)
+
+ @frappe.whitelist()
+ def update_cost(
+ self,
+ update_parent: bool = True,
+ from_child_bom: bool = False,
+ update_hour_rate: bool = True,
+ save: bool = True,
+ ):
+ return BOMCostingService(self).update_cost(
+ update_parent=update_parent,
+ from_child_bom=from_child_bom,
+ update_hour_rate=update_hour_rate,
+ save=save,
+ )
+
+ def update_parent_cost(self, *args, **kwargs):
+ return BOMCostingService(self).update_parent_cost(*args, **kwargs)
+
+ def get_bom_unitcost(self, *args, **kwargs):
+ return BOMCostingService(self).get_bom_unitcost(*args, **kwargs)
+
+ def calculate_cost(self, *args, **kwargs):
+ return BOMCostingService(self).calculate_cost(*args, **kwargs)
+
+ def calculate_op_cost(self, *args, **kwargs):
+ return BOMCostingService(self).calculate_op_cost(*args, **kwargs)
+
+ def update_rate_and_time(self, *args, **kwargs):
+ return BOMCostingService(self).update_rate_and_time(*args, **kwargs)
+
+ def calculate_rm_cost(self, *args, **kwargs):
+ return BOMCostingService(self).calculate_rm_cost(*args, **kwargs)
+
+ def calculate_secondary_items_costs(self, *args, **kwargs):
+ return BOMCostingService(self).calculate_secondary_items_costs(*args, **kwargs)
+
+ def calculate_exploded_cost(self, *args, **kwargs):
+ return BOMCostingService(self).calculate_exploded_cost(*args, **kwargs)
+
+ def get_rm_rate_map(self, *args, **kwargs):
+ return BOMCostingService(self).get_rm_rate_map(*args, **kwargs)
+
+ def update_exploded_items(self, *args, **kwargs):
+ return BOMExplodedItemsService(self).update_exploded_items(*args, **kwargs)
+
+ def get_exploded_items(self, *args, **kwargs):
+ return BOMExplodedItemsService(self).get_exploded_items(*args, **kwargs)
+
+ def add_to_cur_exploded_items(self, *args, **kwargs):
+ return BOMExplodedItemsService(self).add_to_cur_exploded_items(*args, **kwargs)
+
+ def get_child_exploded_items(self, *args, **kwargs):
+ return BOMExplodedItemsService(self).get_child_exploded_items(*args, **kwargs)
+
+ def add_exploded_items(self, *args, **kwargs):
+ return BOMExplodedItemsService(self).add_exploded_items(*args, **kwargs)
+
+
+def _get_bom_children(bom_no):
+ children = frappe.cache().hget("bom_children", bom_no)
+ if children is None:
+ bom_item = frappe.qb.DocType("BOM Item")
+ children = (
+ frappe.qb.from_(bom_item)
+ .select(bom_item.bom_no)
+ .where((bom_item.parent == bom_no) & (bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
+ ).run(pluck=True)
+ frappe.cache().hset("bom_children", bom_no, children)
+ return children
+
def get_bom_item_rate(args, bom_doc):
if bom_doc.rm_cost_as_per == "Valuation Rate":
@@ -1299,42 +1061,59 @@ def get_bom_item_rate(args, bom_doc):
or flt(frappe.db.get_value("Item", args["item_code"], "last_purchase_rate"))
) * (args.get("conversion_factor") or 1)
elif bom_doc.rm_cost_as_per == "Price List":
- if not bom_doc.buying_price_list:
- frappe.throw(_("Please select Price List"))
- ctx = ItemDetailsCtx(
- {
- "doctype": "BOM",
- "price_list": bom_doc.buying_price_list,
- "qty": args.get("qty") or 1,
- "uom": args.get("uom") or args.get("stock_uom"),
- "stock_uom": args.get("stock_uom"),
- "transaction_type": "buying",
- "company": bom_doc.company,
- "currency": bom_doc.currency,
- "conversion_rate": 1, # Passed conversion rate as 1 purposefully, as conversion rate is applied at the end of the function
- "conversion_factor": args.get("conversion_factor") or 1,
- "plc_conversion_rate": 1,
- "ignore_party": True,
- "ignore_conversion_rate": True,
- }
- )
- item_doc = frappe.get_cached_doc("Item", args.get("item_code"))
- price_list_data = get_price_list_rate(ctx, item_doc)
- rate = price_list_data.price_list_rate
+ rate = _get_price_list_item_rate(args, bom_doc)
return flt(rate)
+def _get_price_list_item_rate(args, bom_doc):
+ if not bom_doc.buying_price_list:
+ frappe.throw(_("Please select Price List"))
+
+ ctx = ItemDetailsCtx(
+ {
+ "doctype": "BOM",
+ "price_list": bom_doc.buying_price_list,
+ "qty": args.get("qty") or 1,
+ "uom": args.get("uom") or args.get("stock_uom"),
+ "stock_uom": args.get("stock_uom"),
+ "transaction_type": "buying",
+ "company": bom_doc.company,
+ "currency": bom_doc.currency,
+ "conversion_rate": 1, # Passed conversion rate as 1 purposefully, as conversion rate is applied at the end of the function
+ "conversion_factor": args.get("conversion_factor") or 1,
+ "plc_conversion_rate": 1,
+ "ignore_party": True,
+ "ignore_conversion_rate": True,
+ }
+ )
+ item_doc = frappe.get_cached_doc("Item", args.get("item_code"))
+ price_list_data = get_price_list_rate(ctx, item_doc)
+ return price_list_data.price_list_rate
+
+
def get_valuation_rate(data):
"""
1) Get average valuation rate from all warehouses
2) If no value, get last valuation rate from SLE
3) If no value, get valuation rate from Item
"""
- from pypika import Case
-
item_code, company = data.get("item_code"), data.get("company")
- valuation_rate = 0.0
+
+ valuation_rate = _get_avg_valuation_rate_from_bins(item_code, company, data)
+
+ if (valuation_rate is not None) and valuation_rate <= 0:
+ # Explicit null value check. If None, Bins don't exist, neither does SLE
+ valuation_rate = _get_last_valuation_rate_from_sle(item_code)
+
+ if not valuation_rate:
+ valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")
+
+ return flt(valuation_rate)
+
+
+def _get_avg_valuation_rate_from_bins(item_code, company, data):
+ from pypika import Case
bin_table = frappe.qb.DocType("Bin")
wh_table = frappe.qb.DocType("Warehouse")
@@ -1356,28 +1135,21 @@ def get_valuation_rate(data):
if data.get("set_rate_based_on_warehouse") and data.get("warehouse"):
item_valuation = item_valuation.where(bin_table.warehouse == data.get("warehouse"))
- item_valuation = item_valuation.run(as_dict=True)[0]
+ return item_valuation.run(as_dict=True)[0].get("valuation_rate")
- valuation_rate = item_valuation.get("valuation_rate")
- if (valuation_rate is not None) and valuation_rate <= 0:
- # Explicit null value check. If None, Bins don't exist, neither does SLE
- sle = frappe.qb.DocType("Stock Ledger Entry")
- last_val_rate = (
- frappe.qb.from_(sle)
- .select(sle.valuation_rate)
- .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
- .orderby(sle.posting_datetime, order=frappe.qb.desc)
- .orderby(sle.creation, order=frappe.qb.desc)
- .limit(1)
- ).run(as_dict=True)
+def _get_last_valuation_rate_from_sle(item_code):
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ last_val_rate = (
+ frappe.qb.from_(sle)
+ .select(sle.valuation_rate)
+ .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
+ .orderby(sle.posting_datetime, order=frappe.qb.desc)
+ .orderby(sle.creation, order=frappe.qb.desc)
+ .limit(1)
+ ).run(as_dict=True)
- valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0
-
- if not valuation_rate:
- valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")
-
- return flt(valuation_rate)
+ return flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0
def get_list_context(context):
@@ -1395,117 +1167,193 @@ def get_bom_items_as_dict(
fetch_qty_in_stock_uom=True,
):
item_dict = {}
+ opts = frappe._dict(
+ qty=qty,
+ fetch_exploded=fetch_exploded,
+ fetch_secondary_items=fetch_secondary_items,
+ include_non_stock_items=include_non_stock_items,
+ fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
+ )
- group_by_cond = "group by item_code, stock_uom, operation"
- if frappe.get_cached_value("BOM", bom, "track_semi_finished_goods"):
- fetch_exploded = 0
- group_by_cond = "group by item_code, operation_row_id, stock_uom"
-
- if fetch_secondary_items:
- fetch_exploded = 0
- group_by_cond = "group by item_code"
-
- # Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
- query = """select
- bom_item.item_code,
- bom_item.idx,
- item.item_name,
- sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty,
- item.image,
- bom.project,
- item.stock_uom,
- item.item_group,
- item.allow_alternative_item,
- item_default.default_warehouse,
- item_default.expense_account as expense_account,
- item_default.buying_cost_center as cost_center
- {select_columns}
- from
- `tab{table}` bom_item
- JOIN `tabBOM` bom ON bom_item.parent = bom.name
- JOIN `tabItem` item ON item.name = bom_item.item_code
- LEFT JOIN `tabItem Default` item_default
- ON item_default.parent = item.name and item_default.company = %(company)s
- where
- bom_item.docstatus < 2
- and bom.name = %(bom)s
- and (item.is_stock_item in (1, {is_stock_item})
- {where_conditions}
- {group_by_cond}
- order by idx"""
-
- is_stock_item = cint(not include_non_stock_items)
- if cint(fetch_exploded):
- query = query.format(
- table="BOM Explosion Item",
- where_conditions=")",
- is_stock_item=is_stock_item,
- qty_field="stock_qty",
- group_by_cond=group_by_cond,
- select_columns=""", bom_item.source_warehouse, bom_item.operation,
- bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier,
- sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
- (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""",
- )
-
- items = frappe.db.sql(
- query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
- )
- elif fetch_secondary_items:
- query = query.format(
- table="BOM Secondary Item",
- where_conditions=")",
- select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.is_legacy",
- is_stock_item=is_stock_item,
- qty_field="stock_qty",
- group_by_cond=group_by_cond,
- )
-
- items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True)
- else:
- query = query.format(
- table="BOM Item",
- where_conditions="or bom_item.is_phantom_item)",
- is_stock_item=is_stock_item,
- qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty",
- select_columns=""", bom_item.rate, bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse,
- bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier,
- sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
- bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """,
- group_by_cond=group_by_cond,
- )
- items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True)
+ items = _query_bom_items(bom, company, opts)
for item in items:
- key = item.item_code
- if item.operation_row_id:
- key = (item.item_code, item.operation_row_id)
+ _add_bom_item_to_dict(item_dict, item, company, opts)
- if item.operation:
- key = (item.item_code, item.operation)
+ _set_default_accounts_for_items(item_dict, company)
- if item.get("is_phantom_item"):
- data = get_bom_items_as_dict(
- item.get("bom_no"),
- company,
- qty=item.get("qty"),
- fetch_exploded=fetch_exploded,
- fetch_secondary_items=fetch_secondary_items,
- include_non_stock_items=include_non_stock_items,
- fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
- )
+ return item_dict
- for k, v in data.items():
- if item_dict.get(k):
- item_dict[k]["qty"] += flt(v.qty)
- else:
- item_dict[k] = v
- elif key in item_dict:
- item_dict[key]["qty"] += flt(item.qty)
+def _query_bom_items(bom, company, opts):
+ track_semi_finished_goods = frappe.get_cached_value("BOM", bom, "track_semi_finished_goods")
+ if track_semi_finished_goods or opts.fetch_secondary_items:
+ opts.fetch_exploded = 0
+
+ # Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
+ t = _get_bom_item_tables(opts)
+ query = _build_base_bom_items_query(bom, company, opts.qty, t)
+ query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
+ return query.groupby(*group_by).orderby(Field("idx")).run(as_dict=True)
+
+
+def _get_bom_item_tables(opts):
+ if cint(opts.fetch_exploded):
+ bom_item = frappe.qb.DocType("BOM Explosion Item")
+ qty_field_col = bom_item.stock_qty
+ elif opts.fetch_secondary_items:
+ bom_item = frappe.qb.DocType("BOM Secondary Item")
+ qty_field_col = bom_item.stock_qty
+ else:
+ bom_item = frappe.qb.DocType("BOM Item")
+ qty_field_col = bom_item.stock_qty if opts.fetch_qty_in_stock_uom else bom_item.qty
+
+ return frappe._dict(
+ bom_item=bom_item,
+ qty_field_col=qty_field_col,
+ bom_doc=frappe.qb.DocType("BOM"),
+ item_doc=frappe.qb.DocType("Item"),
+ item_default=frappe.qb.DocType("Item Default"),
+ )
+
+
+def _build_base_bom_items_query(bom, company, qty, t):
+ return (
+ frappe.qb.from_(t.bom_item)
+ .join(t.bom_doc)
+ .on(t.bom_item.parent == t.bom_doc.name)
+ .join(t.item_doc)
+ .on(t.item_doc.name == t.bom_item.item_code)
+ .left_join(t.item_default)
+ .on((t.item_default.parent == t.item_doc.name) & (t.item_default.company == company))
+ .select(
+ t.bom_item.item_code,
+ t.bom_item.idx,
+ t.item_doc.item_name,
+ (Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
+ t.item_doc.image,
+ t.bom_doc.project,
+ t.item_doc.stock_uom,
+ t.item_doc.item_group,
+ t.item_doc.allow_alternative_item,
+ t.item_default.default_warehouse,
+ t.item_default.expense_account.as_("expense_account"),
+ t.item_default.buying_cost_center.as_("cost_center"),
+ )
+ .where((t.bom_item.docstatus < 2) & (t.bom_doc.name == bom))
+ )
+
+
+def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods):
+ is_stock_item = cint(not opts.include_non_stock_items)
+ stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item])
+ amount_col = (Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * t.bom_item.rate * opts.qty).as_(
+ "amount"
+ )
+
+ if cint(opts.fetch_exploded):
+ return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition)
+ if opts.fetch_secondary_items:
+ return _add_secondary_item_columns(query, t, stock_item_condition)
+ return _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods)
+
+
+def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
+ bom_item_table = frappe.qb.DocType("BOM Item")
+ idx_subquery = (
+ frappe.qb.from_(bom_item_table)
+ .select(bom_item_table.idx)
+ .where((bom_item_table.item_code == t.bom_item.item_code) & (bom_item_table.parent == bom))
+ .limit(1)
+ )
+
+ query = query.select(
+ t.bom_item.source_warehouse,
+ t.bom_item.operation,
+ t.bom_item.include_item_in_manufacturing,
+ t.bom_item.description,
+ t.bom_item.rate,
+ t.bom_item.sourced_by_supplier,
+ amount_col,
+ idx_subquery.as_("idx"),
+ ).where(stock_item_condition)
+
+ return query, [t.bom_item.item_code, t.item_doc.stock_uom, t.bom_item.operation]
+
+
+def _add_secondary_item_columns(query, t, stock_item_condition):
+ query = query.select(
+ t.item_doc.description,
+ t.bom_item.cost_allocation_per,
+ t.bom_item.process_loss_per,
+ t.bom_item.secondary_item_type,
+ t.bom_item.name,
+ t.bom_item.is_legacy,
+ ).where(stock_item_condition)
+
+ return query, [t.bom_item.item_code]
+
+
+def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods):
+ query = query.select(
+ t.bom_item.rate,
+ t.bom_item.uom,
+ t.bom_item.conversion_factor,
+ t.bom_item.source_warehouse,
+ t.bom_item.operation,
+ t.bom_item.include_item_in_manufacturing,
+ t.bom_item.sourced_by_supplier,
+ amount_col,
+ t.bom_item.description,
+ t.bom_item.base_rate.as_("rate"),
+ t.bom_item.operation_row_id,
+ t.bom_item.is_phantom_item,
+ t.bom_item.bom_no,
+ ).where(stock_item_condition | (t.bom_item.is_phantom_item == 1))
+
+ if track_semi_finished_goods:
+ group_by = [t.bom_item.item_code, t.bom_item.operation_row_id, t.item_doc.stock_uom]
+ else:
+ group_by = [t.bom_item.item_code, t.item_doc.stock_uom, t.bom_item.operation]
+
+ return query, group_by
+
+
+def _add_bom_item_to_dict(item_dict, item, company, opts):
+ key = item.item_code
+ if item.operation_row_id:
+ key = (item.item_code, item.operation_row_id)
+
+ if item.operation:
+ key = (item.item_code, item.operation)
+
+ if item.get("is_phantom_item"):
+ _merge_phantom_bom_items(item_dict, item, company, opts)
+ elif key in item_dict:
+ item_dict[key]["qty"] += flt(item.qty)
+ else:
+ item_dict[key] = item
+
+
+def _merge_phantom_bom_items(item_dict, item, company, opts):
+ data = get_bom_items_as_dict(
+ item.get("bom_no"),
+ company,
+ qty=item.get("qty"),
+ fetch_exploded=opts.fetch_exploded,
+ fetch_secondary_items=opts.fetch_secondary_items,
+ include_non_stock_items=opts.include_non_stock_items,
+ fetch_qty_in_stock_uom=opts.fetch_qty_in_stock_uom,
+ )
+
+ for k, v in data.items():
+ if item_dict.get(k):
+ item_dict[k]["qty"] += flt(v.qty)
else:
- item_dict[key] = item
+ item_dict[k] = v
+
+def _set_default_accounts_for_items(item_dict, company):
for item, item_details in item_dict.items():
for d in [
["Account", "expense_account", "stock_adjustment_account"],
@@ -1516,8 +1364,6 @@ def get_bom_items_as_dict(
if not item_details.get(d[1]) or (company_in_record and company != company_in_record):
item_dict[item][d[1]] = frappe.get_cached_value("Company", company, d[2]) if d[2] else None
- return item_dict
-
@frappe.whitelist()
def get_bom_items(bom: str, company: str, qty: float = 1, fetch_exploded: int = 1):
@@ -1535,494 +1381,23 @@ def validate_bom_no(item, bom_no):
if bom.docstatus != 1:
if not frappe.in_test:
frappe.throw(_("BOM {0} must be submitted").format(bom_no))
- if item:
- rm_item_exists = False
- for d in bom.items:
- if d.item_code.lower() == item.lower():
- rm_item_exists = True
- for d in bom.secondary_items:
- if d.item_code.lower() == item.lower():
- rm_item_exists = True
- if (
- bom.item.lower() == item.lower()
- or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower()
- ):
- rm_item_exists = True
- if not rm_item_exists:
- frappe.throw(_("BOM {0} does not belong to Item {1}").format(bom_no, item))
+ if item and not _bom_contains_item(bom, item):
+ frappe.throw(_("BOM {0} does not belong to Item {1}").format(bom_no, item))
-@frappe.whitelist()
-def get_children(parent: str | None = None, is_root: bool = False, **filters):
- if not parent or parent == "BOM":
- frappe.msgprint(_("Please select a BOM"))
- return
+def _bom_contains_item(bom, item):
+ item = item.lower()
+ for d in bom.items:
+ if d.item_code.lower() == item:
+ return True
+ for d in bom.secondary_items:
+ if d.item_code.lower() == item:
+ return True
- if parent:
- frappe.form_dict.parent = parent
-
- if frappe.form_dict.parent:
- bom_doc = frappe.get_cached_doc("BOM", frappe.form_dict.parent)
- frappe.has_permission("BOM", doc=bom_doc, throw=True)
-
- bom_items = frappe.get_all(
- "BOM Item",
- fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"],
- filters=[["parent", "=", frappe.form_dict.parent]],
- order_by="idx",
- )
-
- 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]],
- ) # to get only required item dicts
-
- for bom_item in bom_items:
- # extend bom_item dict with respective item dict
- bom_item.update(
- # returns an item dict from items list which matches with item_code
- 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)
-
- return bom_items
-
-
-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",
+ return (
+ bom.item.lower() == item
+ or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower()
)
- 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)
-
- table = "items"
- if work_order and not job_card:
- table = "exploded_items" if work_order.get("use_multi_level_bom") else "items"
-
- 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)
-
- non_stock_items = frappe.get_all(
- "Item",
- fields="name",
- filters=[
- ["name", "in", list(items.keys())],
- [IfNull(Field("is_stock_item"), 0), "=", 0],
- ],
- as_list=1,
- )
-
- non_stock_items_cost = 0.0
- for name in non_stock_items:
- non_stock_items_cost += (
- flt(items.get(name[0])) * flt(stock_entry.fg_completed_qty) / flt(bom.quantity)
- )
-
- if non_stock_items_cost:
- stock_entry.append(
- "additional_costs",
- {
- "expense_account": expense_account,
- "description": _("Non stock items"),
- "amount": non_stock_items_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
-
- from erpnext.stock.doctype.stock_entry.stock_entry import get_consumed_operating_cost
-
- 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
-
- workstation_cost = frappe.get_all(
- "Workstation Cost",
- fields=["operating_component", "operating_cost"],
- filters={
- "parent": row.workstation,
- "parenttype": "Workstation",
- },
- )
-
- consumed_operating_cost = (
- get_consumed_operating_cost(work_order.name, stock_entry.bom_no, row.name) or []
- )
- for wc in workstation_cost:
- expense_account = (
- get_component_account(wc.operating_component, stock_entry.company) or op_expense_account
- )
- consumed_op_cost = next(
- (
- cost
- for cost in consumed_operating_cost
- if cost.get("operating_component") == wc.operating_component
- ),
- {},
- )
- actual_cp_operating_cost = 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"),
- )
-
- remaining_qty = row.completed_qty - consumed_op_cost.get("consumed_qty", 0)
- per_unit_cost = actual_cp_operating_cost / (remaining_qty or 1)
- operating_cost = per_unit_cost * stock_entry.fg_completed_qty
-
- if actual_cp_operating_cost:
- stock_entry.append(
- "additional_costs",
- {
- "expense_account": expense_account,
- "description": _("{0} Operating Cost for operation {1}").format(
- wc.operating_component, row.operation
- ),
- "amount": flt(
- min(operating_cost, actual_cp_operating_cost),
- frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
- ),
- "has_operating_cost": 1,
- "operation_id": row.name,
- "operating_component": wc.operating_component,
- "qty": min(remaining_qty, stock_entry.fg_completed_qty),
- },
- )
-
- cost_added = True
-
- return cost_added
-
-
-@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:
- cost_added = add_operating_cost_component_wise(
- stock_entry,
- work_order,
- expense_account,
- job_card=job_card,
- )
-
- if not cost_added and not job_card:
- stock_entry.append(
- "additional_costs",
- {
- "expense_account": expense_account,
- "description": _("Operating Cost as per Work Order / BOM"),
- "amount": flt(
- remaining_operating_cost * stock_entry.fg_completed_qty,
- frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
- ),
- "has_operating_cost": 1,
- },
- )
-
- if work_order and work_order.additional_operating_cost and work_order.qty:
- additional_operating_cost_per_unit = flt(work_order.additional_operating_cost) / flt(work_order.qty)
-
- if additional_operating_cost_per_unit:
- stock_entry.append(
- "additional_costs",
- {
- "expense_account": expense_account,
- "description": "Additional Operating Cost",
- "amount": additional_operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
- },
- )
-
- def get_max_operation_quantity():
- 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 get_utilised_corrective_cost():
- table = frappe.qb.DocType("Stock Entry")
- subquery = (
- frappe.qb.from_(table)
- .select(table.name)
- .where(
- (table.docstatus == 1)
- & (table.work_order == work_order.name)
- & (table.purpose == "Manufacture")
- )
- )
- table = frappe.qb.DocType("Landed Cost Taxes and Charges")
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.amount).as_("amount"))
- .where(table.parent.isin(subquery) & (table.has_corrective_cost == 1))
- )
- return query.run(as_dict=True)[0].amount or 0
-
- if (
- work_order
- and work_order.corrective_operation_cost
- and cint(
- frappe.db.get_single_value(
- "Manufacturing Settings", "add_corrective_operation_cost_in_finished_good_valuation"
- )
- )
- ):
- max_qty = get_max_operation_quantity() - work_order.produced_qty
- remaining_corrective_cost = work_order.corrective_operation_cost - get_utilised_corrective_cost()
- stock_entry.append(
- "additional_costs",
- {
- "expense_account": expense_account,
- "description": "Corrective Operation Cost",
- "has_corrective_cost": 1,
- "amount": remaining_corrective_cost / max_qty * flt(stock_entry.fg_completed_qty),
- },
- )
-
-
-@frappe.whitelist()
-def get_bom_diff(bom1: str, bom2: str):
- from frappe.model import table_fields
-
- 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 = []
-
- meta = doc1.meta
-
- identifiers = {
- "operations": "operation",
- "items": "item_code",
- "secondary_items": "item_code",
- "exploded_items": "item_code",
- }
-
- for df in meta.fields:
- old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname)
-
- if df.fieldtype in table_fields:
- identifier = identifiers[df.fieldname]
- # make maps
- old_row_by_identifier, new_row_by_identifier = {}, {}
- for d in old_value:
- old_row_by_identifier[d.get(identifier)] = d
- for d in new_value:
- new_row_by_identifier[d.get(identifier)] = d
-
- # check rows for additions, changes
- for i, d in enumerate(new_value):
- if d.get(identifier) in old_row_by_identifier:
- diff = get_diff(old_row_by_identifier[d.get(identifier)], d, for_child=True)
- if diff and diff.changed:
- out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed))
- else:
- out.added.append([df.fieldname, d.as_dict()])
-
- # check for deletions
- for d in old_value:
- if d.get(identifier) not in new_row_by_identifier:
- out.removed.append([df.fieldname, d.as_dict()])
-
- return out
-
-
-@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
-):
- meta = frappe.get_meta("Item", cached=True)
- searchfields = meta.get_search_fields()
-
- order_by = "idx desc, name, item_name"
-
- fields = ["name", "item_name", "item_group", "description"]
- fields.extend([field for field in searchfields if field not in ["name", "item_group", "description"]])
-
- if not searchfields:
- searchfields = ["name"]
-
- query_filters = [
- ["disabled", "=", 0],
- [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()],
- ]
-
- or_cond_filters = {}
- if txt:
- for s_field in searchfields:
- or_cond_filters[s_field] = ("like", f"%{txt}%")
-
- barcodes = frappe.get_all(
- "Item Barcode",
- fields=["parent as item_code"],
- filters={"barcode": ("like", f"%{txt}%")},
- distinct=True,
- )
-
- barcodes = [d.item_code for d in barcodes]
- if barcodes:
- or_cond_filters["name"] = ("in", barcodes)
-
- if filters and filters.get("item_code"):
- has_variants = frappe.get_cached_value("Item", filters.get("item_code"), "has_variants")
- if not has_variants:
- query_filters.append(["has_variants", "=", 0])
-
- if filters:
- for fieldname, value in filters.items():
- query_filters.append([fieldname, "=", value])
-
- return frappe.get_list(
- "Item",
- fields=fields,
- filters=query_filters,
- or_filters=or_cond_filters,
- order_by=order_by,
- limit_start=start,
- limit_page_length=page_len,
- as_list=1,
- )
-
-
-@frappe.whitelist()
-def make_variant_bom(
- source_name: str,
- bom_no: str,
- item: str,
- variant_items: str | list,
- target_doc: Document | str | None = None,
-):
- from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item
-
- def postprocess(source, doc):
- doc.item = item
- doc.quantity = 1
-
- item_data = get_item_details(item)
- 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)
-
- doc = get_mapped_doc(
- "BOM",
- source_name,
- {
- "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,
- },
- },
- target_doc,
- postprocess,
- )
-
- return doc
-
-
-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):
- if not secondary_items:
- secondary_items = {}
-
- bom_items = frappe.get_all(
- "BOM Item",
- filters={"parent": bom_no, "docstatus": 1},
- fields=["bom_no", "qty"],
- order_by="idx asc",
- )
-
- for row in bom_items:
- 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 get_backflush_based_on(bom_no=None):
diff --git a/erpnext/manufacturing/doctype/bom/mapper.py b/erpnext/manufacturing/doctype/bom/mapper.py
new file mode 100644
index 00000000000..7f4dafcdfec
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom/mapper.py
@@ -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)
diff --git a/erpnext/manufacturing/doctype/bom/services/__init__.py b/erpnext/manufacturing/doctype/bom/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/doctype/bom/services/costing.py b/erpnext/manufacturing/doctype/bom/services/costing.py
new file mode 100644
index 00000000000..86ed2c97a9a
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom/services/costing.py
@@ -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
diff --git a/erpnext/manufacturing/doctype/bom/services/exploded_items.py b/erpnext/manufacturing/doctype/bom/services/exploded_items.py
new file mode 100644
index 00000000000..46646e72e29
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom/services/exploded_items.py
@@ -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()
diff --git a/erpnext/manufacturing/doctype/bom/services/operations_cost.py b/erpnext/manufacturing/doctype/bom/services/operations_cost.py
new file mode 100644
index 00000000000..decc7c28363
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom/services/operations_cost.py
@@ -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"
+ )
diff --git a/erpnext/manufacturing/doctype/production_plan/mapper.py b/erpnext/manufacturing/doctype/production_plan/mapper.py
new file mode 100644
index 00000000000..1cbb8a55e5c
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/mapper.py
@@ -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
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index f670ffb6344..4e8ff017fae 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -2,37 +2,51 @@
# For license information, please see license.txt
-import copy
-import json
-from collections import defaultdict
-
import frappe
-from frappe import _, msgprint
+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 (
- add_days,
- ceil,
- cint,
- comma_and,
- flt,
- get_filtered_list_link,
- get_link_to_form,
- getdate,
- now_datetime,
- nowdate,
- parse_json,
-)
-from frappe.utils.csvutils import build_csv_response
-from pypika.terms import ExistsCriterion
+from frappe.utils import flt
-from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
-from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
-from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
-from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
-from erpnext.stock.get_item_details import get_conversion_factor
+
+# Backward-compatible re-exports (moved to mapper.py / services/).
+from erpnext.manufacturing.doctype.production_plan.mapper import (
+ get_so_details,
+ sales_order_query,
+)
+from erpnext.manufacturing.doctype.production_plan.services.material_request import (
+ MaterialRequestService,
+ download_raw_materials,
+ get_bin_details,
+ get_exploded_items,
+ get_item_data,
+ get_items_for_material_requests,
+ get_material_request_items,
+ get_materials_from_other_locations,
+ get_raw_materials_of_sub_assembly_items,
+ get_sales_orders,
+ get_subitems,
+ get_uom_conversion_factor,
+ get_warehouse_list,
+ set_default_warehouses,
+)
+from erpnext.manufacturing.doctype.production_plan.services.sales_order_sourcing import (
+ SalesOrderSourcingService,
+)
+from erpnext.manufacturing.doctype.production_plan.services.stock_reservation import (
+ cancel_stock_reservation_entries,
+ get_non_completed_production_plans,
+ get_reserved_qty_for_production_plan,
+ get_reserved_qty_for_sub_assembly,
+ make_stock_reservation_entries,
+ reserve_stock_for_production_plan,
+)
+from erpnext.manufacturing.doctype.production_plan.services.sub_assembly import (
+ SubAssemblyService,
+)
+from erpnext.manufacturing.doctype.production_plan.services.work_order_creation import (
+ WorkOrderCreationService,
+)
from erpnext.stock.utils import get_or_make_bin
from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -206,368 +220,6 @@ class ProductionPlan(Document):
if sub_assy.production_plan_item not in actual_names:
sub_assy.production_plan_item = new_name_map.get(sub_assy.production_plan_item)
- @frappe.whitelist()
- def get_open_sales_orders(self):
- """Pull sales orders which are pending to deliver based on criteria selected"""
- open_so = get_sales_orders(self)
-
- 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.set("sales_orders", [])
-
- for data in open_so:
- self.append(
- "sales_orders",
- {
- "sales_order": data.name,
- "sales_order_date": data.transaction_date,
- "customer": data.customer,
- "grand_total": data.base_grand_total,
- },
- )
-
- @frappe.whitelist()
- def get_pending_material_requests(self):
- """Pull Material Requests that are pending based on criteria selected"""
-
- bom = frappe.qb.DocType("BOM")
- mr = frappe.qb.DocType("Material Request")
- mr_item = frappe.qb.DocType("Material Request Item")
-
- pending_mr_query = (
- 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.company)
- & (mr_item.qty > IfNull(mr_item.ordered_qty, 0))
- & (
- ExistsCriterion(
- frappe.qb.from_(bom)
- .select(bom.name)
- .where((bom.item == mr_item.item_code) & (bom.is_active == 1))
- )
- )
- )
- )
-
- if self.from_date:
- pending_mr_query = pending_mr_query.where(mr.transaction_date >= self.from_date)
-
- if self.to_date:
- pending_mr_query = pending_mr_query.where(mr.transaction_date <= self.to_date)
-
- if self.warehouse:
- pending_mr_query = pending_mr_query.where(mr_item.warehouse == self.warehouse)
-
- if self.item_code:
- pending_mr_query = pending_mr_query.where(mr_item.item_code == self.item_code)
-
- pending_mr = pending_mr_query.run(as_dict=True)
-
- self.add_mr_in_table(pending_mr)
-
- def add_mr_in_table(self, pending_mr):
- """Add Material Requests in the table"""
- self.set("material_requests", [])
-
- for data in pending_mr:
- self.append(
- "material_requests",
- {"material_request": data.name, "material_request_date": data.transaction_date},
- )
-
- @frappe.whitelist()
- def combine_so_items(self):
- if self.combine_items and self.po_items and len(self.po_items) > 0:
- items = []
- for row in self.po_items:
- items.append(
- 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,
- }
- )
- )
-
- self.set("po_items", [])
- self.add_items(items)
- else:
- self.get_items()
-
- @frappe.whitelist()
- def get_items(self):
- self.set("po_items", [])
- if self.get_items_from == "Sales Order":
- self.get_so_items()
-
- elif self.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.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.item_code, "docstatus": 1})
-
- if not has_bom:
- bom = frappe.qb.DocType("BOM")
- template_item = frappe.db.get_value("Item", self.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.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")
-
- 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.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,
- )
- .distinct()
- .where(
- (so_item.parent.isin(so_list))
- & (so_item.docstatus == 1)
- & ((so_item.stock_qty - so_item.stock_reserved_qty) > so_item.work_order_qty)
- )
- )
-
- if self.item_code and frappe.db.exists("Item", self.item_code):
- items_query = items_query.where(so_item.item_code == self.item_code)
- items_subquery = items_subquery.where(
- self.get_bom_item_condition() or bom.item == so_item.item_code
- )
-
- items_query = items_query.where(ExistsCriterion(items_subquery))
-
- items = items_query.run(as_dict=True)
-
- for item in items:
- item.pending_qty = flt(item.qty) - max(
- item.work_order_qty, flt(item.delivered_qty) * item.conversion_factor, 0
- )
-
- pi = frappe.qb.DocType("Packed Item")
-
- 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)
- )
-
- packed_items_query = (
- frappe.qb.from_(so_item)
- .from_(pi)
- .select(
- pi.parent,
- pi.item_code,
- pi.warehouse.as_("warehouse"),
- pending_qty.as_("pending_qty"),
- pi.parent_item,
- pi.description,
- so_item.name,
- )
- .distinct()
- .where(
- (so_item.parent == pi.parent)
- & (so_item.docstatus == 1)
- & (pi.parent_item == so_item.item_code)
- & (so_item.parent.isin(so_list))
- & (
- (
- (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)
- )
- )
- & (
- ExistsCriterion(
- frappe.qb.from_(bom)
- .select(bom.name)
- .where((bom.item == pi.item_code) & (bom.is_active == 1))
- )
- )
- )
- )
-
- if self.item_code:
- packed_items_query = packed_items_query.where(so_item.item_code == self.item_code)
-
- packed_items = packed_items_query.run(as_dict=True)
-
- self.add_items(items + packed_items)
- self.calculate_total_planned_qty()
-
- def get_mr_items(self):
- # Check for empty table or empty rows
- if not self.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")
-
- bom = frappe.qb.DocType("BOM")
- mr_item = frappe.qb.DocType("Material Request Item")
-
- items_query = (
- frappe.qb.from_(mr_item)
- .select(
- 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"),
- )
- .distinct()
- .where(
- (mr_item.parent.isin(mr_list))
- & (mr_item.docstatus == 1)
- & (mr_item.qty > mr_item.ordered_qty)
- & (
- ExistsCriterion(
- frappe.qb.from_(bom)
- .select(bom.name)
- .where((bom.item == mr_item.item_code) & (bom.is_active == 1))
- )
- )
- )
- )
-
- if self.item_code:
- items_query = items_query.where(mr_item.item_code == self.item_code)
-
- items = items_query.run(as_dict=True)
-
- self.add_items(items)
- self.calculate_total_planned_qty()
-
- 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.combine_items:
- bom_no = item_details.get("bom_no")
- if data.get("bom_no"):
- bom_no = data.get("bom_no")
-
- if bom_no in refs:
- refs[bom_no]["so_details"].append(
- {"sales_order": data.parent, "sales_order_item": data.name, "qty": data.pending_qty}
- )
- refs[bom_no]["qty"] += data.pending_qty
- continue
-
- else:
- refs[bom_no] = {
- "qty": data.pending_qty,
- "po_item_ref": data.name,
- "so_details": [],
- }
- refs[bom_no]["so_details"].append(
- {"sales_order": data.parent, "sales_order_item": data.name, "qty": data.pending_qty}
- )
-
- bom_no = data.bom_no or item_details and item_details.get("bom_no") or ""
- if not bom_no:
- continue
-
- pi = self.append(
- "po_items",
- {
- "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,
- },
- )
- pi._set_defaults()
-
- if self.get_items_from == "Sales Order":
- pi.sales_order = data.parent
- pi.sales_order_item = data.name
- pi.description = data.description
-
- elif self.get_items_from == "Material Request":
- pi.material_request = data.parent
- pi.material_request_item = data.name
- pi.description = data.description
-
- if refs:
- for po_item in self.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.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 calculate_total_produced_qty(self):
self.total_produced_qty = 0
for d in self.po_items:
@@ -603,7 +255,7 @@ class ProductionPlan(Document):
if not self.reserve_stock:
return
- make_stock_reservation_entries(self)
+ reserve_stock_for_production_plan(self)
def add_reference_to_raw_materials(self):
for item in self.mr_items:
@@ -721,1553 +373,90 @@ class ProductionPlan(Document):
self.status = "Material Requested"
break
- def get_production_items(self):
- item_dict = {}
-
- for d in self.po_items:
- item_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.company,
- "source_warehouse": frappe.get_value("BOM", d.bom_no, "default_source_warehouse"),
- "fg_warehouse": d.warehouse,
- "production_plan": self.name,
- "production_plan_item": d.name,
- "product_bundle_item": d.product_bundle_item,
- "planned_start_date": d.planned_start_date,
- "project": self.project,
- }
-
- key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
- if self.combine_items:
- key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
-
- if not d.sales_order:
- key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
-
- if not item_details["project"] and d.sales_order:
- item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
-
- if self.get_items_from == "Material Request":
- item_details.update({"qty": d.planned_qty})
- item_dict[
- (d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
- ] = item_details
- else:
- item_details.update(
- {
- "qty": flt(item_dict.get(key, {}).get("qty"))
- + (flt(d.planned_qty) - flt(d.ordered_qty))
- }
- )
- item_dict[key] = item_details
-
- return item_dict
+ def get_production_items(self, *args, **kwargs):
+ return WorkOrderCreationService(self).get_production_items(*args, **kwargs)
@frappe.whitelist()
def make_work_order(self):
- from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
+ return WorkOrderCreationService(self).make_work_order()
- wo_list, po_list = [], []
- subcontracted_po = {}
- default_warehouses = get_default_warehouse(self.company)
+ def make_work_order_for_finished_goods(self, *args, **kwargs):
+ return WorkOrderCreationService(self).make_work_order_for_finished_goods(*args, **kwargs)
- 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)
+ def make_work_order_for_subassembly_items(self, *args, **kwargs):
+ return WorkOrderCreationService(self).make_work_order_for_subassembly_items(*args, **kwargs)
- if not wo_list:
- frappe.msgprint(_("No Work Orders were created"))
+ def prepare_data_for_sub_assembly_items(self, *args, **kwargs):
+ return WorkOrderCreationService(self).prepare_data_for_sub_assembly_items(*args, **kwargs)
- if not po_list:
- frappe.msgprint(_("No Purchase Orders were created"))
+ def make_subcontracted_purchase_order(self, *args, **kwargs):
+ return WorkOrderCreationService(self).make_subcontracted_purchase_order(*args, **kwargs)
- def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
- items_data = self.get_production_items()
+ def show_list_created_message(self, *args, **kwargs):
+ return WorkOrderCreationService(self).show_list_created_message(*args, **kwargs)
- for _key, item in items_data.items():
- if self.sub_assembly_items:
- item["use_multi_level_bom"] = 0
+ def create_work_order(self, *args, **kwargs):
+ return WorkOrderCreationService(self).create_work_order(*args, **kwargs)
- set_default_warehouses(item, default_warehouses)
- work_order = self.create_work_order(item)
- if work_order:
- wo_list.append(work_order)
+ @frappe.whitelist()
+ def get_open_sales_orders(self):
+ return SalesOrderSourcingService(self).get_open_sales_orders()
- def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
- for row in self.sub_assembly_items:
- if row.type_of_manufacturing == "Subcontract":
- subcontracted_po.setdefault(row.supplier, []).append(row)
- continue
+ def add_so_in_table(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).add_so_in_table(*args, **kwargs)
- if row.type_of_manufacturing == "Material Request":
- continue
+ @frappe.whitelist()
+ def get_pending_material_requests(self):
+ return SalesOrderSourcingService(self).get_pending_material_requests()
- 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.get("company"),
- }
+ def add_mr_in_table(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).add_mr_in_table(*args, **kwargs)
- if flt(row.qty) <= flt(row.ordered_qty):
- continue
+ @frappe.whitelist()
+ def combine_so_items(self):
+ return SalesOrderSourcingService(self).combine_so_items()
- self.prepare_data_for_sub_assembly_items(row, work_order_data)
+ @frappe.whitelist()
+ def get_items(self):
+ return SalesOrderSourcingService(self).get_items()
- if work_order_data.get("qty") <= 0:
- continue
+ def get_so_mr_list(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).get_so_mr_list(*args, **kwargs)
- work_order = self.create_work_order(work_order_data)
- if work_order:
- wo_list.append(work_order)
+ def get_bom_item_condition(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).get_bom_item_condition(*args, **kwargs)
- def prepare_data_for_sub_assembly_items(self, row, wo_data):
- for field in [
- "production_item",
- "item_name",
- "fg_warehouse",
- "description",
- "bom_no",
- "stock_uom",
- "bom_level",
- "schedule_date",
- "sales_order",
- "sales_order_item",
- ]:
- if row.get(field):
- wo_data[field] = row.get(field)
+ def get_so_items(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).get_so_items(*args, **kwargs)
- wo_data["qty"] = flt(row.get("qty")) - flt(row.get("ordered_qty"))
+ def get_mr_items(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).get_mr_items(*args, **kwargs)
- wo_data.update(
- {
- "use_multi_level_bom": 0,
- "production_plan": self.name,
- "production_plan_sub_assembly_item": row.name,
- }
- )
+ def add_items(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).add_items(*args, **kwargs)
- def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
- if not subcontracted_po:
- return
+ def add_pp_ref(self, *args, **kwargs):
+ return SalesOrderSourcingService(self).add_pp_ref(*args, **kwargs)
- def calculate_sub_assembly_items():
- 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}
-
- subcontracted_po = calculate_sub_assembly_items()
-
- for supplier, po_list in subcontracted_po.items():
- po = frappe.new_doc("Purchase Order")
- po.company = self.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_data = {
- "fg_item": row.production_item,
- "warehouse": row.fg_warehouse,
- "production_plan_sub_assembly_item": row.name,
- "bom": row.bom_no,
- "production_plan": self.name,
- "fg_item_qty": row.qty,
- }
-
- for field in [
- "schedule_date",
- "qty",
- "description",
- "production_plan_item",
- "sales_order",
- "sales_order_item",
- ]:
- po_data[field] = row.get(field)
-
- po.append("items", po_data)
-
- po.set_service_items_for_finished_goods()
- po.set_missing_values()
- po.flags.ignore_mandatory = True
- po.flags.ignore_validate = True
- po.insert()
- purchase_orders.append(po.name)
-
- 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 = frappe.new_doc("Work Order")
- wo.update(item)
- if not wo.source_warehouse:
- wo.source_warehouse = item.get("fg_warehouse")
-
- wo.reserve_stock = self.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)
-
- try:
- wo.flags.ignore_mandatory = True
- wo.flags.ignore_validate = True
- wo.company = self.company
- wo.insert()
- return wo.name
- except OverProductionError:
- pass
-
- def validate_mr_subcontracted(self):
- for row in self.mr_items:
- if row.material_request_type == "Subcontracting":
- 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 validate_mr_subcontracted(self, *args, **kwargs):
+ return MaterialRequestService(self).validate_mr_subcontracted(*args, **kwargs)
@frappe.whitelist()
def make_material_request(self):
- self.validate_mr_subcontracted()
-
- """Create Material Requests grouped by Sales Order and Material Request Type"""
- material_request_list = []
- material_request_map = {}
-
- if all([item.requested_qty == item.quantity for item in self.mr_items]):
- msgprint(_("All items are already requested"))
- return
-
- for item in self.mr_items:
- if item.quantity == item.requested_qty:
- continue
-
- 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, "")
- schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
-
- if key not in material_request_map:
- # make a new MR for the combination
- material_request_map[key] = frappe.new_doc("Material Request")
- material_request = material_request_map[key]
- material_request.update(
- {
- "transaction_date": nowdate(),
- "status": "Draft",
- "company": self.company,
- "material_request_type": material_request_type,
- }
- )
- material_request_list.append(material_request)
- else:
- material_request = material_request_map[key]
-
- # add item
- material_request.append(
- "items",
- {
- "item_code": item.item_code,
- "from_warehouse": item.from_warehouse
- if material_request_type == "Material Transfer"
- else None,
- "qty": item.quantity - item.requested_qty,
- "uom": item.uom,
- "schedule_date": schedule_date,
- "warehouse": item.warehouse,
- "sales_order": item.sales_order,
- "production_plan": self.name,
- "material_request_plan_item": item.name,
- "project": frappe.db.get_value("Sales Order", item.sales_order, "project")
- if item.sales_order
- else None,
- },
- )
-
- for material_request in material_request_list:
- # submit
- material_request.flags.ignore_permissions = 1
- material_request.run_method("set_missing_values")
-
- material_request.save()
- if self.get("submit_material_request"):
- material_request.submit()
-
- frappe.flags.mute_messages = False
-
- if material_request_list:
- material_request_list = [
- get_link_to_form("Material Request", m.name) for m in material_request_list
- ]
- msgprint(_("{0} created").format(comma_and(material_request_list)))
- else:
- msgprint(_("No material request created"))
+ return MaterialRequestService(self).make_material_request()
@frappe.whitelist()
def get_sub_assembly_items(self, manufacturing_type: str | None = None):
- "Fetch sub assembly items and optionally combine them."
- self.sub_assembly_items = []
- sub_assembly_items_store = [] # temporary store to process all subassembly items
- bin_details = frappe._dict()
+ return SubAssemblyService(self).get_sub_assembly_items(manufacturing_type=manufacturing_type)
- track_semi_finished_goods = True
- for row in self.po_items:
- if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse:
- frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx))
+ def set_sub_assembly_items_based_on_level(self, *args, **kwargs):
+ return SubAssemblyService(self).set_sub_assembly_items_based_on_level(*args, **kwargs)
- if not row.item_code:
- frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
+ def set_default_supplier_for_subcontracting_order(self, *args, **kwargs):
+ return SubAssemblyService(self).set_default_supplier_for_subcontracting_order(*args, **kwargs)
- if not row.bom_no:
- frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx))
+ def combine_subassembly_items(self, *args, **kwargs):
+ return SubAssemblyService(self).combine_subassembly_items(*args, **kwargs)
- if frappe.db.get_value("BOM", row.bom_no, "track_semi_finished_goods"):
- 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)
- )
- continue
-
- bom_data = []
-
- track_semi_finished_goods = False
-
- 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.company,
- warehouse=self.sub_assembly_warehouse,
- skip_available_sub_assembly_item=self.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)
-
- if (
- not track_semi_finished_goods
- and not sub_assembly_items_store
- and self.skip_available_sub_assembly_item
- ):
- message = (
- _(
- "As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
- ).format(self.sub_assembly_warehouse)
- + "
"
- )
- message += _("If you still want to proceed, please disable '{0}' checkbox.").format(
- self.meta.get_field("skip_available_sub_assembly_item").label
- )
-
- frappe.msgprint(message, title=_("Note"))
-
- if self.combine_sub_items:
- # Combine subassembly 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.append("sub_assembly_items", row)
-
- self.set_default_supplier_for_subcontracting_order()
-
- 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.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.sub_assembly_warehouse
-
- if not self.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.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
- ]
-
- if not items:
- return
-
- default_supplier = frappe._dict(
- frappe.get_all(
- "Item Default",
- fields=["parent", "default_supplier"],
- filters={"parent": ("in", items), "default_supplier": ("is", "set")},
- as_list=1,
- )
- )
-
- if not default_supplier:
- return
-
- for row in self.sub_assembly_items:
- if row.type_of_manufacturing != "Subcontract":
- continue
-
- row.supplier = default_supplier.get(row.production_item)
-
- 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"),
- )
- if key not in key_wise_data:
- # intialise (item, wh, bom no, man.g type) wise dict
- key_wise_data[key] = row
- continue
-
- existing_row = key_wise_data[key]
- if existing_row:
- # if row with same (item, wh, bom no, man.g type) key, merge
- 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)
- continue
- else:
- # add row with key
- key_wise_data[key] = row
-
- sub_assembly_items_store = [
- key_wise_data[key] for key in key_wise_data
- ] # unpack into single level list
- return sub_assembly_items_store
-
- def all_items_completed(self):
- all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 for d in self.po_items)
- if not all_items_produced:
- return False
-
- wo_status = frappe.get_all(
- "Work Order",
- filters={
- "production_plan": self.name,
- "status": ("not in", ["Closed", "Stopped"]),
- "docstatus": 1,
- },
- fields="status",
- pluck="status",
- )
- all_work_orders_completed = all(s == "Completed" for s in wo_status)
- return all_work_orders_completed
-
-
-@frappe.whitelist()
-def download_raw_materials(doc: str | dict | Document, warehouses: str | list | None = None):
- if isinstance(doc, str):
- doc = frappe._dict(json.loads(doc))
-
- item_list = [
- [
- "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",
- ]
- ]
-
- 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)
-
- 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:
- rm_data = duplicate_item_wh_list[key]
- rm_data[12] += d.get("quantity")
- continue
-
- rm_data = [
- 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"),
- ]
-
- duplicate_item_wh_list[key] = rm_data
- item_list.append(rm_data)
-
- if not doc.get("for_warehouse"):
- 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),
- ]
- )
-
- build_csv_response(item_list, doc.name)
-
-
-def get_exploded_items(item_details, company, bom_no, include_non_stock_items, planned_qty=1, doc=None):
- 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")
-
- data = (
- 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(
- (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"),
- )
- .where(
- (bei.docstatus < 2)
- & (bei.is_sub_assembly_item == 0)
- & (bom.name == bom_no)
- & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
- )
- .groupby(bei.item_code, bei.stock_uom)
- ).run(as_dict=True)
-
- 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)
-
- return 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"
- )
-
-
-def get_subitems(
- doc,
- data,
- item_details,
- bom_no,
- company,
- include_non_stock_items,
- include_subcontracted_items,
- parent_qty,
- planned_qty=1,
-):
- 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")
-
- items = (
- 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(
- bom_item.item_code,
- item.default_material_request_type,
- item.item_name,
- IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_(
- "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,
- )
- .where(
- (bom.name == bom_no)
- & (bom_item.is_sub_assembly_item == 0)
- & (bom_item.docstatus < 2)
- & (
- (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
- | (bom_item.is_phantom_item == 1)
- )
- )
- .groupby(bom_item.item_code)
- .orderby(bom_item.idx)
- ).run(as_dict=True)
-
- for d in items:
- if not data.get("include_exploded_items") or not d.default_bom:
- if d.item_code in item_details:
- item_details[d.item_code].qty = item_details[d.item_code].qty + d.qty
- else:
- 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
-
- if d.is_phantom_item or (data.get("include_exploded_items") and d.default_bom):
- if (
- (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
- ):
- if d.qty > 0:
- get_subitems(
- doc,
- data,
- item_details,
- d.default_bom,
- company,
- include_non_stock_items,
- include_subcontracted_items,
- d.qty,
- )
- return {key: value for key, value in item_details.items() if not value.get("is_phantom_item")}
-
-
-def get_material_request_items(
- doc,
- row,
- sales_order,
- company,
- ignore_existing_ordered_qty,
- include_safety_stock,
- warehouse,
- bin_dict,
- consumed_qty,
-):
- required_qty = 0
- item_code = row.get("item_code")
-
- if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
- required_qty = flt(row.get("qty"))
- else:
- key = (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 required_qty > 0 and required_qty < row["min_order_qty"]:
- required_qty = row["min_order_qty"]
-
- item_group_defaults = get_item_group_defaults(row.item_code, company)
-
- 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"])
-
- item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
-
- conversion_factor = 1.0
- if (
- row.get("default_material_request_type") == "Purchase"
- and item_details.purchase_uom
- and item_details.purchase_uom != item_details.stock_uom
- ):
- conversion_factor = (
- get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
- )
-
- 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
- or row.get("source_warehouse")
- or row.get("default_warehouse")
- or item_group_defaults.get("default_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_sales_orders(self):
- bom = frappe.qb.DocType("BOM")
- pi = frappe.qb.DocType("Packed Item")
- so = frappe.qb.DocType("Sales Order")
- so_item = frappe.qb.DocType("Sales Order Item")
-
- open_so_subquery1 = frappe.qb.from_(bom).select(bom.name).where(bom.is_active == 1)
-
- open_so_subquery2 = (
- frappe.qb.from_(pi)
- .select(pi.name)
- .where(
- (pi.parent == so.name)
- & (pi.parent_item == so_item.item_code)
- & (
- ExistsCriterion(
- frappe.qb.from_(bom)
- .select(bom.name)
- .where((bom.item == pi.item_code) & (bom.is_active == 1))
- )
- )
- )
- )
-
- open_so_query = (
- 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 == self.company)
- & (so_item.qty > so_item.production_plan_qty)
- )
- )
-
- date_field_mapper = {
- "from_date": so.transaction_date >= self.from_date,
- "to_date": so.transaction_date <= self.to_date,
- "from_delivery_date": so_item.delivery_date >= self.from_delivery_date,
- "to_delivery_date": so_item.delivery_date <= self.to_delivery_date,
- }
-
- for field, value in date_field_mapper.items():
- if self.get(field):
- open_so_query = open_so_query.where(value)
-
- for field in ("customer", "project", "sales_order_status"):
- if self.get(field):
- so_field = "status" if field == "sales_order_status" else field
- open_so_query = open_so_query.where(so[so_field] == self.get(field))
-
- if self.item_code and frappe.db.exists("Item", self.item_code):
- open_so_query = open_so_query.where(so_item.item_code == self.item_code)
- open_so_subquery1 = open_so_subquery1.where(
- self.get_bom_item_condition() or bom.item == so_item.item_code
- )
-
- open_so_query = open_so_query.where(
- ExistsCriterion(open_so_subquery1) | ExistsCriterion(open_so_subquery2)
- )
-
- open_so = open_so_query.run(as_dict=True)
-
- return open_so
-
-
-@frappe.whitelist()
-def get_bin_details(
- row: str | dict, company: str, for_warehouse: str | None = None, all_warehouse: bool = False
-):
- if isinstance(row, str):
- row = frappe._dict(json.loads(row))
-
- bin = frappe.qb.DocType("Bin")
- 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))
-
- query = (
- frappe.qb.from_(bin)
- .select(
- bin.warehouse,
- 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"),
- )
- .where((bin.item_code == row["item_code"]) & (bin.warehouse.isin(subquery)))
- .groupby(bin.item_code, bin.warehouse)
- )
-
- return query.run(as_dict=True)
-
-
-@frappe.whitelist()
-def get_so_details(sales_order: str):
- return frappe.db.get_value(
- "Sales Order", sales_order, ["transaction_date", "customer", "grand_total"], as_dict=1
- )
-
-
-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_items_for_material_requests(
- doc: str | frappe._dict | Document,
- warehouses: str | list | None = None,
- get_parent_warehouse_data: bool | int | None = None,
-):
- if isinstance(doc, str):
- doc = frappe._dict(json.loads(doc))
-
- if warehouses:
- warehouses = list(set(get_warehouse_list(warehouses)))
-
- if (
- doc.get("for_warehouse")
- and not get_parent_warehouse_data
- and doc.get("for_warehouse") in warehouses
- ):
- warehouses.remove(doc.get("for_warehouse"))
-
- doc["mr_items"] = []
-
- po_items = doc.get("po_items") if doc.get("po_items") else doc.get("items")
-
- if doc.get("sub_assembly_items"):
- for sa_row in doc.sub_assembly_items:
- sa_row = frappe._dict(sa_row)
- if sa_row.type_of_manufacturing == "Material Request":
- po_items.append(
- frappe._dict(
- {
- "item_code": sa_row.production_item,
- "required_qty": sa_row.qty,
- "include_exploded_items": 0,
- }
- )
- )
-
- # Check for empty table or empty rows
- 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"),
- )
-
- company = doc.get("company")
- ignore_existing_ordered_qty = doc.get("ignore_existing_ordered_qty")
- include_safety_stock = doc.get("include_safety_stock")
-
- so_item_details = frappe._dict()
- existing_sub_assembly_items = set()
-
- sub_assembly_items = defaultdict(int)
- if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"):
- for d in doc.get("sub_assembly_items"):
- sub_assembly_items[
- (d.get("production_item"), d.get("bom_no"), d.get("type_of_manufacturing"))
- ] += d.get("qty")
- sub_assembly_items = {k[:2]: v for k, v in sub_assembly_items.items()}
-
- for data in po_items:
- if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
- data["include_exploded_items"] = 1
-
- planned_qty = data.get("required_qty") or data.get("planned_qty")
- ignore_existing_ordered_qty = data.get("ignore_existing_ordered_qty") or ignore_existing_ordered_qty
- warehouse = doc.get("for_warehouse")
-
- item_details = {}
- if data.get("bom") or data.get("bom_no"):
- if data.get("required_qty"):
- bom_no = data.get("bom")
- include_non_stock_items = 1
- include_subcontracted_items = 1 if data.get("include_exploded_items") else 0
- else:
- bom_no = data.get("bom_no")
- include_subcontracted_items = doc.get("include_subcontracted_items")
- include_non_stock_items = doc.get("include_non_stock_items")
-
- if not planned_qty:
- frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx")))
-
- if bom_no:
- if (
- data.get("include_exploded_items")
- and doc.get("skip_available_sub_assembly_item")
- and doc.get("sub_assembly_items")
- ):
- item_details = 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,
- )
- elif data.get("include_exploded_items") and include_subcontracted_items:
- # fetch exploded items from BOM
- item_details = get_exploded_items(
- item_details,
- company,
- bom_no,
- include_non_stock_items,
- planned_qty=planned_qty,
- doc=doc,
- )
- else:
- item_details = get_subitems(
- doc,
- data,
- item_details,
- bom_no,
- company,
- include_non_stock_items,
- include_subcontracted_items,
- 1,
- planned_qty=planned_qty,
- )
- elif data.get("item_code"):
- 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
- )
-
- item_details[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,
- }
- )
-
- sales_order = data.get("sales_order")
- qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
-
- 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.get(sales_order, {}):
- so_item_details[sales_order][key]["qty"] = so_item_details[sales_order][key].get(
- "qty", 0
- ) + flt(details.qty)
- else:
- so_item_details[sales_order][key] = details
-
- mr_items = []
- consumed_qty = defaultdict(float)
-
- for sales_order in so_item_details:
- item_dict = so_item_details[sales_order]
- for details in item_dict.values():
- warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
- bin_dict = get_bin_details(details, doc.company, warehouse)
- bin_dict = bin_dict[0] if bin_dict else {}
-
- if details.qty > 0:
- items = get_material_request_items(
- doc,
- details,
- sales_order,
- company,
- ignore_existing_ordered_qty,
- include_safety_stock,
- warehouse,
- bin_dict,
- consumed_qty,
- )
- if items:
- mr_items.append(items)
-
- if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
- new_mr_items = []
- for item in mr_items:
- get_materials_from_other_locations(item, warehouses, new_mr_items, company)
-
- mr_items = new_mr_items
-
- if not mr_items:
- 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)
- + "
"
- )
- message += _("If you still want to proceed, please enable {0}.").format(to_enable)
-
- frappe.msgprint(message, title=_("Note"))
-
- return mr_items
-
-
-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
-
- purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
-
- 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")
-
- # get available material by transferring to production warehouse
- for d in locations:
- if required_qty <= 0:
- return
-
- 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)
-
- # raise purchase request for remaining qty
-
- precision = frappe.get_precision("Material Request Plan Item", "quantity")
- if flt(required_qty, precision) > 0:
- 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 get_item_data(item_code: str):
- 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 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,
-):
- data = get_bom_children(parent=bom_no)
- precision = frappe.get_precision("Production Plan Sub Assembly Item", "qty")
- for d in data:
- if d.expandable:
- parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
- stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
- required_qty = stock_qty
-
- if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items:
- bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
-
- 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:
- if _bin_dict.original_projected_qty >= stock_qty:
- _bin_dict.original_projected_qty -= stock_qty
- stock_qty = 0
- continue
- else:
- stock_qty = stock_qty - _bin_dict.original_projected_qty
- sub_assembly_items.append(d.item_code)
- elif warehouse:
- bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
-
- if not d.is_phantom_item:
- bom_data.append(
- frappe._dict(
- {
- "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0)
- if bin_details.get(d.item_code)
- else 0,
- "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": bin_details[d.item_code][0].get("projected_qty", 0)
- if bin_details.get(d.item_code)
- else 0,
- "main_bom": bom_no,
- }
- )
- )
-
- 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 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_reserved_qty_for_production_plan(item_code, warehouse):
- from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
-
- table = frappe.qb.DocType("Production Plan")
- child = frappe.qb.DocType("Material Request Plan Item")
-
- non_completed_production_plans = get_non_completed_production_plans()
-
- query = (
- frappe.qb.from_(table)
- .inner_join(child)
- .on(table.name == child.parent)
- .select(
- Sum(
- Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)
- * child.conversion_factor
- )
- )
- .where(
- (table.docstatus == 1)
- & (child.item_code == item_code)
- & (child.warehouse == warehouse)
- & (table.status.notin(["Completed", "Closed"]))
- )
- )
-
- if non_completed_production_plans:
- query = query.where(table.name.isin(non_completed_production_plans))
-
- query = query.run()
-
- if not query or query[0][0] is None:
- return None
-
- reserved_qty_for_production_plan = flt(query[0][0])
-
- reserved_qty_for_production = flt(
- get_reserved_qty_for_production(
- item_code, warehouse, non_completed_production_plans, check_production_plan=True
- )
- )
-
- if reserved_qty_for_production > reserved_qty_for_production_plan:
- return 0.0
-
- return reserved_qty_for_production_plan - reserved_qty_for_production
-
-
-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_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,
-):
- 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")
-
- query = (
- 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(
- (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"),
- )
- .where(
- (bei.docstatus == 1)
- & (bei.is_sub_assembly_item == 0)
- & (bom.name == bom_no)
- & (
- (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
- | (bei.is_phantom_item == 1)
- )
- )
- .groupby(bei.item_code, bei.stock_uom)
- )
-
- for item in query.run(as_dict=True):
- 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:
- continue
-
- if not item.is_phantom_item and existing_key in existing_sub_assembly_items:
- continue
-
- if item.bom_no:
- 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)
- else:
- if not item.conversion_factor and item.purchase_uom:
- item.conversion_factor = get_uom_conversion_factor(item.item_code, item.purchase_uom)
-
- if details := item_details.get((item.get("item_code"), item.get("main_bom"))):
- details.qty += item.get("qty")
- else:
- item_details.setdefault((item.get("item_code"), item.get("main_bom")), item)
-
- return item_details
-
-
-@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)
-
- if not filters:
- filters = {}
-
- 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))
- )
-
- 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}%"))
-
- if page_len:
- query = query.limit(page_len)
-
- if start:
- query = query.offset(start)
-
- return query.run()
-
-
-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")
-
- query = (
- frappe.qb.from_(table)
- .inner_join(child)
- .on(table.name == child.parent)
- .select(
- Sum(
- Case().when(child.qty > 0, child.qty).else_(child.required_qty)
- - IfNull(child.wo_produced_qty, 0)
- )
- )
- .where(
- (table.docstatus == 1)
- & (child.production_item == item_code)
- & (child.fg_warehouse == warehouse)
- & (table.status.notin(["Completed", "Closed"]))
- )
- )
-
- query = query.run()
-
- if not query or query[0][0] is None:
- return None
-
- qty = flt(query[0][0])
- return qty if qty > 0 else 0.0
-
-
-@frappe.whitelist()
-def make_stock_reservation_entries(
- doc: str | Document, items: str | list | None = None, table_name: str | None = None, notify: bool = False
-):
- if isinstance(doc, str):
- doc = parse_json(doc)
- doc = frappe.get_doc("Production Plan", doc.get("name"))
-
- if items and isinstance(items, str):
- items = parse_json(items)
-
- mapper = {
- "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",
- },
- }
-
- for child_table_name in mapper:
- if table_name and table_name != child_table_name:
- continue
-
- sre = StockReservation(doc, items=items, kwargs=mapper[child_table_name])
- if doc.docstatus == 1:
- sre_created = sre.make_stock_reservation_entries()
- if sre_created:
- frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
- elif doc.docstatus == 2:
- sre.cancel_stock_reservation_entries()
-
- doc.reload()
-
-
-@frappe.whitelist()
-def cancel_stock_reservation_entries(doc: str | Document, sre_list: str | list):
- if isinstance(doc, str):
- doc = parse_json(doc)
- doc = frappe.get_doc("Production Plan", doc.get("name"))
-
- sre = StockReservation(doc)
- sre.cancel_stock_reservation_entries(sre_list)
-
- doc.reload()
+ def all_items_completed(self, *args, **kwargs):
+ return SubAssemblyService(self).all_items_completed(*args, **kwargs)
diff --git a/erpnext/manufacturing/doctype/production_plan/services/__init__.py b/erpnext/manufacturing/doctype/production_plan/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py b/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py
new file mode 100644
index 00000000000..93bcb935959
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py
@@ -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
+ )
diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
new file mode 100644
index 00000000000..750c627883e
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
@@ -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)
+ + "
"
+ )
+ 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),
+ ]
+ )
diff --git a/erpnext/manufacturing/doctype/production_plan/services/planning_helpers.py b/erpnext/manufacturing/doctype/production_plan/services/planning_helpers.py
new file mode 100644
index 00000000000..87ccdd2b5c7
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/planning_helpers.py
@@ -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)
+ )
diff --git a/erpnext/manufacturing/doctype/production_plan/services/sales_order_sourcing.py b/erpnext/manufacturing/doctype/production_plan/services/sales_order_sourcing.py
new file mode 100644
index 00000000000..cc0d96b1639
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/sales_order_sourcing.py
@@ -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
+ )
diff --git a/erpnext/manufacturing/doctype/production_plan/services/stock_reservation.py b/erpnext/manufacturing/doctype/production_plan/services/stock_reservation.py
new file mode 100644
index 00000000000..73a0c89990e
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/stock_reservation.py
@@ -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()
diff --git a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py
new file mode 100644
index 00000000000..ac6fe40f500
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py
@@ -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)
+ + "
"
+ )
+ 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)
diff --git a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_planning.py b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_planning.py
new file mode 100644
index 00000000000..9a529feabf7
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_planning.py
@@ -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)
diff --git a/erpnext/manufacturing/doctype/production_plan/services/work_order_creation.py b/erpnext/manufacturing/doctype/production_plan/services/work_order_creation.py
new file mode 100644
index 00000000000..91ec2bf52e6
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan/services/work_order_creation.py
@@ -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}
diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py
index e2230a1367f..009551bb301 100644
--- a/erpnext/manufacturing/doctype/work_order/mapper.py
+++ b/erpnext/manufacturing/doctype/work_order/mapper.py
@@ -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")
diff --git a/erpnext/manufacturing/doctype/work_order/services/__init__.py b/erpnext/manufacturing/doctype/work_order/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/doctype/work_order/services/operations.py b/erpnext/manufacturing/doctype/work_order/services/operations.py
new file mode 100644
index 00000000000..e3249c31204
--- /dev/null
+++ b/erpnext/manufacturing/doctype/work_order/services/operations.py
@@ -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
diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py
new file mode 100644
index 00000000000..61d718065d3
--- /dev/null
+++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py
@@ -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,
+ }
diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py
new file mode 100644
index 00000000000..ca4c3af8900
--- /dev/null
+++ b/erpnext/manufacturing/doctype/work_order/services/status.py
@@ -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]
+ )
diff --git a/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py
new file mode 100644
index 00000000000..b698979f67e
--- /dev/null
+++ b/erpnext/manufacturing/doctype/work_order/services/stock_reservation.py
@@ -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)
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index b9b35e5bdec..fa12845ae48 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -2,43 +2,66 @@
# License: GNU General Public License v3. See license.txt
import json
-from collections import defaultdict
import frappe
-from dateutil.relativedelta import relativedelta
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,
- date_diff,
flt,
- get_datetime,
get_link_to_form,
- getdate,
now,
nowdate,
- parse_json,
- time_diff_in_hours,
)
-from pypika import functions as fn
from erpnext.buying.utils import check_on_hold_or_closed_status
-from erpnext.manufacturing.doctype.bom.bom import (
- get_bom_item_rate,
- get_bom_items_as_dict,
- validate_bom_no,
+from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
+
+# Backward-compatible re-exports: these functions were moved to mapper.py.
+# Importing them here preserves existing whitelist dotted-paths
+# (e.g. ...work_order.work_order.make_work_order) and external imports.
+from erpnext.manufacturing.doctype.work_order.mapper import (
+ add_variant_item,
+ check_if_scrap_warehouse_mandatory,
+ create_job_card,
+ create_pick_list,
+ get_item_details,
+ get_operation_details,
+ get_serial_nos_for_job_card,
+ get_serial_nos_for_work_order,
+ get_template_rm_item,
+ get_work_order_operation_data,
+ make_job_card,
+ make_stock_entry,
+ make_stock_return_entry,
+ make_work_order,
+ split_qty_based_on_batch_size,
+ validate_operation_data,
)
-from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
- get_mins_between_operations,
+from erpnext.manufacturing.doctype.work_order.services.operations import (
+ OperationsService,
+)
+from erpnext.manufacturing.doctype.work_order.services.required_items import (
+ RequiredItemsService,
+)
+from erpnext.manufacturing.doctype.work_order.services.status import (
+ StatusService,
+)
+from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
+ StockReservationService,
+ cancel_stock_reservation_entries,
+ get_consumed_qty,
+ get_reserved_qty_for_production,
+ get_row_wise_serial_batch,
+ get_sre_details,
+ make_stock_reservation_entries,
)
from erpnext.stock.doctype.batch.batch import make_batch
-from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
-from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
-from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
-from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
-from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company
+from erpnext.stock.doctype.item.item import validate_end_of_life
+from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos
+from erpnext.stock.utils import validate_warehouse_company
from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -206,8 +229,8 @@ class WorkOrder(Document):
self.status = self.get_status()
self.validate_workstation_type()
self.reset_use_multi_level_bom()
- self.set_reserve_stock()
- self.validate_fg_warehouse_for_reservation()
+ StockReservationService(self).set_reserve_stock()
+ StockReservationService(self).validate_fg_warehouse_for_reservation()
self.validate_dates()
if self.source_warehouse:
@@ -220,7 +243,7 @@ class WorkOrder(Document):
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
- self.enable_auto_reserve_stock()
+ StockReservationService(self).enable_auto_reserve_stock()
self.validate_operations_sequence()
self.validate_subcontracting_inward_order()
@@ -229,35 +252,6 @@ class WorkOrder(Document):
if self.actual_end_date < self.actual_start_date:
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
- def validate_fg_warehouse_for_reservation(self):
- if (
- self.reserve_stock
- and self.sales_order
- and not self.subcontracting_inward_order
- and not self.production_plan_sub_assembly_item
- ):
- warehouses = frappe.get_all(
- "Sales Order Item",
- filters={"parent": self.sales_order, "item_code": self.production_item},
- pluck="warehouse",
- )
-
- if self.fg_warehouse not in warehouses:
- frappe.throw(
- _("Warehouse {0} is not allowed for Sales Order {1}, it should be {2}").format(
- self.fg_warehouse, self.sales_order, warehouses[0]
- ),
- title=_("Target Warehouse Reservation Error"),
- )
-
- def set_reserve_stock(self):
- for row in self.required_items:
- row.reserve_stock = self.reserve_stock
-
- def enable_auto_reserve_stock(self):
- if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
- self.reserve_stock = 1
-
def before_save(self):
self.set_skip_transfer_for_operations()
@@ -514,183 +508,6 @@ class WorkOrder(Document):
for wh in warehouses:
validate_warehouse_company(wh, self.company)
- def calculate_operating_cost(self):
- self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0
- for d in self.get("operations"):
- if not d.hour_rate:
- if 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")
- )
-
- self.planned_operating_cost += flt(d.planned_operating_cost)
- self.actual_operating_cost += flt(d.actual_operating_cost)
-
- variable_cost = (
- self.actual_operating_cost if self.actual_operating_cost else self.planned_operating_cost
- )
-
- self.total_operating_cost = (
- flt(self.additional_operating_cost) + flt(variable_cost) + flt(self.corrective_operation_cost)
- )
-
- def validate_work_order_against_so(self):
- # already ordered qty
- ordered_qty_against_so = frappe.db.sql(
- """select sum(qty - process_loss_qty) from `tabWork Order`
- where production_item = %s and sales_order = %s and docstatus = 1 and status != 'Closed' and name != %s""",
- (self.production_item, self.sales_order, self.name),
- )[0][0]
-
- total_qty = flt(ordered_qty_against_so) + flt(self.qty)
-
- # get qty from Sales Order Item table
- so_item_qty = frappe.db.sql(
- """select sum(stock_qty) from `tabSales Order Item`
- where parent = %s and item_code = %s and docstatus = 1""",
- (self.sales_order, self.production_item),
- )[0][0]
- # get qty from Packing Item table
- dnpi_qty = frappe.db.sql(
- """select sum(qty) from `tabPacked Item`
- where parent = %s and parenttype = 'Sales Order' and item_code = %s and docstatus = 1""",
- (self.sales_order, self.production_item),
- )[0][0]
- # total qty in SO
- so_qty = flt(so_item_qty) + flt(dnpi_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):
- frappe.throw(
- _("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format(
- get_link_to_form("Item", self.production_item),
- frappe.bold(so_qty),
- frappe.bold(frappe.get_value("Item", self.production_item, "stock_uom")),
- ),
- OverProductionError,
- )
-
- def update_status(self, status=None):
- """Update status of work order if unknown"""
- if self.status != "Closed":
- if status not in ["Stopped", "Closed"]:
- status = self.get_status(status)
-
- if status != self.status:
- self.db_set("status", status)
-
- self.update_required_items()
-
- return status or self.status
-
- def get_status(self, status=None):
- """Return the status based on stock entries against this work order"""
- if not status:
- status = self.status
-
- if self.docstatus == 0:
- status = "Draft"
- elif self.docstatus == 1:
- if status not in ["Closed", "Stopped"]:
- status = "Not Started"
- if flt(self.material_transferred_for_manufacturing) > 0:
- status = "In Process"
-
- precision = frappe.get_precision("Work Order", "produced_qty")
- total_qty = flt(self.produced_qty, precision) + flt(self.process_loss_qty, precision)
- if flt(total_qty, precision) >= flt(self.qty, precision):
- status = "Completed"
- else:
- status = "Cancelled"
-
- if (
- self.skip_transfer
- and self.produced_qty
- and self.qty > (flt(self.produced_qty) + flt(self.process_loss_qty))
- ):
- status = "In Process"
-
- if status != "Completed":
- if not all(d.status == "Pending" for d in self.operations):
- status = "In Process"
-
- if status == "Not Started" and self.reserve_stock:
- for row in self.required_items:
- if not row.stock_reserved_qty:
- continue
-
- if row.stock_reserved_qty >= row.required_qty:
- status = "Stock Reserved"
- else:
- status = "Stock Partially Reserved"
- break
-
- return status
-
- def update_work_order_qty(self):
- """Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
- based on Stock Entry"""
-
- if self.track_semi_finished_goods:
- return
-
- allowance_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
- )
-
- for purpose, fieldname in (
- ("Manufacture", "produced_qty"),
- ("Material Transfer for Manufacture", "material_transferred_for_manufacturing"),
- ("Material Transfer for Manufacture", "additional_transferred_qty"),
- ):
- if (
- purpose == "Material Transfer for Manufacture"
- and self.operations
- and self.transfer_material_against == "Job Card"
- ):
- continue
-
- qty = self.get_transferred_or_manufactured_qty(purpose, fieldname)
-
- if not allowance_percentage and purpose == "Material Transfer for Manufacture":
- allowance_percentage = flt(
- frappe.db.get_single_value(
- "Manufacturing Settings", "transfer_extra_materials_percentage"
- )
- )
-
- completed_qty = self.qty + (allowance_percentage / 100 * self.qty)
- if qty > completed_qty:
- frappe.throw(
- _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
- _(self.meta.get_label(fieldname)), qty, completed_qty, self.name
- ),
- StockOverProductionError,
- )
-
- self.db_set(fieldname, qty)
- self.set_process_loss_qty()
-
- from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
-
- if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item:
- update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
-
- if self.production_plan:
- self.set_produced_qty_for_sub_assembly_item()
- self.update_production_plan_status()
-
- if self.additional_transferred_qty:
- self.validate_additional_transferred_qty()
-
def validate_additional_transferred_qty(self):
transfer_extra_materials_percentage = frappe.db.get_single_value(
"Manufacturing Settings", "transfer_extra_materials_percentage"
@@ -712,73 +529,6 @@ class WorkOrder(Document):
).format(actual_qty, allowed_qty),
)
- def update_disassembled_qty(self, qty, is_cancel=False):
- if is_cancel:
- self.disassembled_qty = max(0, self.disassembled_qty - qty)
- else:
- if self.docstatus == 1:
- self.disassembled_qty += qty
-
- if not is_cancel and self.disassembled_qty > self.produced_qty:
- frappe.throw(_("Cannot disassemble more than produced quantity."))
-
- self.db_set("disassembled_qty", self.disassembled_qty)
-
- def get_transferred_or_manufactured_qty(self, purpose, fieldname):
- parent = frappe.qb.DocType("Stock Entry")
-
- query = frappe.qb.from_(parent).where(
- (parent.work_order == self.name)
- & (parent.docstatus == 1)
- & (parent.purpose == purpose)
- & (parent.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty"))
- )
-
- 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 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.name) & (table.purpose == "Manufacture") & (table.docstatus == 1)
- )
- ).run()[0][0]
-
- self.db_set("process_loss_qty", flt(process_loss_qty))
-
- def update_production_plan_status(self):
- production_plan = frappe.get_doc("Production Plan", self.production_plan)
- produced_qty = 0
- if self.production_plan_item:
- total_qty = frappe.get_all(
- "Work Order",
- fields=[{"SUM": "produced_qty", "as": "produced_qty"}],
- filters={
- "docstatus": 1,
- "production_plan": self.production_plan,
- "production_plan_item": self.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.production_plan_item)
-
def validate_warehouse(self):
if self.track_semi_finished_goods:
return
@@ -804,10 +554,10 @@ class WorkOrder(Document):
self.update_reserved_qty_for_production()
self.update_completed_qty_in_material_request()
self.update_planned_qty()
- self.create_job_card()
+ self.create_job_card_from_wo()
if self.reserve_stock:
- self.update_stock_reservation()
+ StockReservationService(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
@@ -831,15 +581,10 @@ class WorkOrder(Document):
self.update_reserved_qty_for_production()
if self.reserve_stock:
- self.update_stock_reservation()
+ StockReservationService(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
- def update_stock_reservation(self):
- self.set_qty_change()
- make_stock_reservation_entries(self)
- self.db_set("status", self.get_status())
-
def set_qty_change(self):
if scio_item_name := self.get("subcontracting_inward_order_item"):
self.qty_change = frappe._dict()
@@ -1025,77 +770,6 @@ class WorkOrder(Document):
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
- 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.operations):
- qty = self.qty
- while qty > 0:
- qty = split_qty_based_on_batch_size(self, 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.operations and self.operations[-1].planned_end_time
- if planned_end_date:
- self.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, 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
-
- if date_diff(row.planned_end_time, self.planned_start_date) > plan_days:
- frappe.message_log.pop()
- frappe.throw(
- _(
- "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"),
- ),
- CapacityError,
- )
-
- row.db_update()
-
- 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.planned_start_date
- elif self.operations[idx - 1].sequence_id:
- if self.operations[idx - 1].sequence_id == row.sequence_id:
- row.planned_start_time = self.operations[idx - 1].planned_start_time
- else:
- last_ops_with_same_sequence_ids = sorted(
- [op for op in self.operations if op.sequence_id == self.operations[idx - 1].sequence_id],
- key=lambda op: get_datetime(op.planned_end_time),
- )
- row.planned_start_time = (
- get_datetime(last_ops_with_same_sequence_ids[-1].planned_end_time)
- + get_mins_between_operations()
- )
- else:
- row.planned_start_time = (
- get_datetime(self.operations[idx - 1].planned_end_time) + 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 validate_cancel(self):
if self.status == "Stopped":
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))
@@ -1113,310 +787,6 @@ class WorkOrder(Document):
)
)
- def update_planned_qty(self):
- if self.track_semi_finished_goods:
- return
-
- from erpnext.manufacturing.doctype.production_plan.production_plan import (
- get_reserved_qty_for_sub_assembly,
- )
-
- qty_dict = {"planned_qty": get_planned_qty(self.production_item, self.fg_warehouse)}
-
- if self.production_plan_sub_assembly_item and self.production_plan:
- qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly(
- self.production_item, self.fg_warehouse
- )
-
- update_bin_qty(
- self.production_item,
- self.fg_warehouse,
- qty_dict,
- )
-
- if self.material_request:
- mr_obj = frappe.get_doc("Material Request", self.material_request)
- mr_obj.update_requested_qty([self.material_request_item])
-
- def set_produced_qty_for_sub_assembly_item(self):
- table = frappe.qb.DocType("Work Order")
-
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.produced_qty))
- .where(
- (table.production_plan == self.production_plan)
- & (table.production_plan_sub_assembly_item == self.production_plan_sub_assembly_item)
- & (table.docstatus == 1)
- )
- ).run()
-
- produced_qty = flt(query[0][0]) if query else 0
-
- frappe.db.set_value(
- "Production Plan Sub Assembly Item",
- self.production_plan_sub_assembly_item,
- "wo_produced_qty",
- produced_qty,
- )
-
- def update_ordered_qty(self):
- if self.production_plan and (self.production_plan_item or self.production_plan_sub_assembly_item):
- table = frappe.qb.DocType("Work Order")
-
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.qty))
- .where((table.production_plan == self.production_plan) & (table.docstatus == 1))
- )
-
- if self.production_plan_item:
- query = query.where(table.production_plan_item == self.production_plan_item)
- elif self.production_plan_sub_assembly_item:
- query = query.where(
- table.production_plan_sub_assembly_item == self.production_plan_sub_assembly_item
- )
-
- query = query.run()
- qty = flt(query[0][0]) if query else 0
-
- if self.production_plan_item:
- frappe.db.set_value("Production Plan Item", self.production_plan_item, "ordered_qty", qty)
- elif self.production_plan_sub_assembly_item:
- frappe.db.set_value(
- "Production Plan Sub Assembly Item",
- self.production_plan_sub_assembly_item,
- "ordered_qty",
- qty,
- )
-
- doc = frappe.get_doc("Production Plan", self.production_plan)
- doc.set_status()
- doc.db_set("status", doc.status)
-
- def update_work_order_qty_in_so(self):
- if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
- return
-
- total_bundle_qty = 1
- if self.product_bundle_item:
- total_bundle_qty = frappe.db.sql(
- """ select sum(qty) from
- `tabProduct Bundle Item` where parent = %s""",
- (frappe.db.escape(self.product_bundle_item)),
- )[0][0]
-
- if not total_bundle_qty:
- # product bundle is 0 (product bundle allows 0 qty for items)
- total_bundle_qty = 1
-
- cond = "product_bundle_item = %s" if self.product_bundle_item else "production_item = %s"
-
- qty = frappe.db.sql(
- f""" select sum(qty) from
- `tabWork Order` where sales_order = %s and docstatus = 1 and status <> 'Closed' and {cond}
- """,
- (self.sales_order, (self.product_bundle_item or self.production_item)),
- as_list=1,
- )
-
- work_order_qty = qty[0][0] if qty and qty[0][0] else 0
- frappe.db.set_value(
- "Sales Order Item",
- self.sales_order_item,
- "work_order_qty",
- flt(work_order_qty / total_bundle_qty, 2),
- )
-
- def update_work_order_qty_in_combined_so(self):
- total_bundle_qty = 1
- if self.product_bundle_item:
- total_bundle_qty = frappe.db.sql(
- """ select sum(qty) from
- `tabProduct Bundle Item` where parent = %s""",
- (frappe.db.escape(self.product_bundle_item)),
- )[0][0]
-
- if not total_bundle_qty:
- # product bundle is 0 (product bundle allows 0 qty for items)
- total_bundle_qty = 1
-
- prod_plan = frappe.get_doc("Production Plan", self.production_plan)
- item_reference = frappe.get_value(
- "Production Plan Item", self.production_plan_item, "sales_order_item"
- )
-
- for plan_reference in prod_plan.prod_plan_references:
- work_order_qty = 0.0
- if plan_reference.item_reference == item_reference:
- if self.docstatus == 1:
- work_order_qty = flt(plan_reference.qty) / total_bundle_qty
- frappe.db.set_value(
- "Sales Order Item", plan_reference.sales_order_item, "work_order_qty", work_order_qty
- )
-
- def update_completed_qty_in_material_request(self):
- if self.material_request and self.material_request_item:
- frappe.get_doc("Material Request", self.material_request).update_completed_qty(
- [self.material_request_item]
- )
-
- def set_work_order_operations(self):
- """Fetch operations from BOM and set in 'Work Order'"""
-
- def _get_operations(bom_no, qty=1, exploded=False):
- data = frappe.get_all(
- "BOM Operation",
- filters={"parent": bom_no},
- 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",
- ],
- order_by="idx",
- )
-
- for d in data:
- if not d.fixed_time:
- if frappe.get_value("Operation", d.operation, "create_job_card_based_on_batch_size"):
- qty = d.batch_size
-
- if exploded:
- d.time_in_mins *= flt(qty)
- else:
- d.time_in_mins /= flt(qty)
-
- d.status = "Pending"
-
- if self.track_semi_finished_goods and not d.sequence_id:
- d.sequence_id = d.idx
-
- return data
-
- self.set("operations", [])
- if not self.bom_no or not frappe.get_cached_value("BOM", self.bom_no, "with_operations"):
- return
-
- operations = []
-
- if self.use_multi_level_bom:
- bom_tree = frappe.get_doc("BOM", self.bom_no).get_tree_representation()
- bom_traversal = reversed(bom_tree.level_order_traversal())
-
- for node in bom_traversal:
- if node.is_bom:
- operations.extend(
- _get_operations(node.name, qty=node.exploded_qty / node.bom_qty, exploded=True)
- )
-
- bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
- operations.extend(_get_operations(self.bom_no, qty=bom_qty))
-
- for correct_index, operation in enumerate(operations, start=1):
- operation.idx = correct_index
-
- self.set("operations", operations)
- self.calculate_time()
-
- def calculate_time(self):
- for d in self.get("operations"):
- if not d.fixed_time:
- d.time_in_mins = flt(d.time_in_mins) * flt(self.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.qty) + (allowance_percentage / 100 * flt(self.qty))
-
- for d in self.get("operations"):
- precision = d.precision("completed_qty")
- qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
- if not qty:
- d.status = "Pending"
- elif qty < flt(self.qty, precision):
- d.status = "Work in Progress"
- elif qty == flt(self.qty, precision):
- d.status = "Completed"
- elif qty <= flt(max_allowed_qty_for_wo, precision):
- d.status = "Completed"
- else:
- frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
-
- def set_actual_dates(self):
- if self.get("operations"):
- actual_start_dates = [d.actual_start_time for d in self.get("operations") if d.actual_start_time]
- if actual_start_dates:
- self.actual_start_date = min(actual_start_dates)
-
- actual_end_dates = [d.actual_end_time for d in self.get("operations") if d.actual_end_time]
- if actual_end_dates:
- self.actual_end_date = max(actual_end_dates)
- else:
- data = frappe.get_all(
- "Stock Entry",
- fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}],
- filters={
- "work_order": self.name,
- "purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]),
- },
- )
-
- if data and len(data):
- dates = [d.posting_datetime for d in data]
- self.db_set("actual_start_date", min(dates))
-
- if self.status == "Completed":
- self.db_set("actual_end_date", max(dates))
-
- self.set_lead_time()
-
- def set_lead_time(self):
- if self.actual_start_date and self.actual_end_date:
- self.lead_time = flt(time_diff_in_hours(self.actual_end_date, self.actual_start_date) * 60)
-
def validate_production_item(self):
if frappe.get_cached_value("Item", self.production_item, "has_variants"):
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
@@ -1502,271 +872,6 @@ class WorkOrder(Document):
if d.time_in_mins <= 0:
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))
- def update_required_items(self):
- """
- update bin reserved_qty_for_production
- called from Stock Entry for production, after submit, cancel
- """
- if self.docstatus == 1:
- self.update_returned_qty()
-
- # calculate consumed qty based on submitted stock entries
- self.update_consumed_qty_for_required_items()
-
- if self.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()
-
- self.validate_reserved_qty()
-
- def update_reserved_qty_for_production(self, items=None):
- """update reserved_qty_for_production in bins"""
- for d in self.required_items:
- if d.source_warehouse:
- stock_bin = get_bin(d.item_code, d.source_warehouse)
- stock_bin.update_reserved_qty_for_production()
-
- @frappe.whitelist()
- def get_items_and_operations_from_bom(self):
- self.set_required_items()
- self.set_work_order_operations()
-
- return check_if_scrap_warehouse_mandatory(self.bom_no)
-
- def set_available_qty(self):
- for d in self.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.wip_warehouse:
- d.available_qty_at_wip_warehouse = get_latest_stock_qty(d.item_code, self.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.required_items = []
-
- operation = None
- if self.get("operations") and len(self.operations) == 1:
- operation = self.operations[0].operation
-
- if self.bom_no and self.qty:
- item_dict = get_bom_items_as_dict(
- self.bom_no, self.company, qty=self.qty, fetch_exploded=self.use_multi_level_bom
- )
-
- if reset_only_qty:
- for d in self.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
- else:
- for item in sorted(item_dict.values(), key=lambda d: d["idx"] or float("inf")):
- self.append(
- "required_items",
- {
- "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": (
- self.source_warehouse or item.source_warehouse or item.default_warehouse
- )
- if not reset_source_warehouse
- else self.source_warehouse,
- "include_item_in_manufacturing": item.include_item_in_manufacturing,
- "operation_row_id": item.operation_row_id,
- },
- )
-
- if self.subcontracting_inward_order and not frappe.get_cached_value(
- "Item", item.item_code, "is_customer_provided_item"
- ):
- self.required_items[-1].source_warehouse = item.default_warehouse
-
- if not self.project:
- self.project = item.get("project")
-
- self.set_available_qty()
-
- def update_transferred_qty_for_required_items(self):
- if self.skip_transfer:
- 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(
- (ste.docstatus == 1)
- & (ste.work_order == self.name)
- & (ste.purpose == "Material Transfer for Manufacture")
- & (ste.is_return == 0)
- )
- .groupby(ste_child.item_code)
- )
-
- data = query.run(as_dict=1) or []
- transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
-
- row_wise_serial_batch = frappe._dict({})
- if self.reserve_stock:
- row_wise_serial_batch = get_row_wise_serial_batch(self.name)
-
- for row in self.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.reserve_stock:
- self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
-
- def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
- if names := frappe.get_all(
- "Stock Reservation Entry",
- filters={
- "voucher_no": self.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:
- doc = frappe.get_doc("Stock Reservation Entry", name)
- qty_to_update = 0.0
- if transferred_qty < 0:
- continue
-
- if transferred_qty > flt(doc.reserved_qty - doc.consumed_qty):
- qty_to_update = doc.reserved_qty - doc.transferred_qty
- transferred_qty -= qty_to_update
- else:
- qty_to_update = transferred_qty
- transferred_qty = 0.0
-
- if qty_to_update < 0:
- continue
-
- 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_returned_qty(self):
- 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(
- (ste.docstatus == 1)
- & (ste.work_order == self.name)
- & (ste.purpose == "Material Transfer for Manufacture")
- & (ste.is_return == 1)
- )
- .groupby(ste_child.item_code)
- )
-
- data = query.run(as_dict=1) or []
- returned_dict = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
-
- for row in self.required_items:
- row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
-
- 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.wip_warehouse
- if self.skip_transfer and not self.from_wip_warehouse:
- wip_warehouse = None
-
- for item in self.required_items:
- consumed_qty = get_consumed_qty(self.name, item.item_code) + item.returned_qty
- item.db_set("consumed_qty", flt(consumed_qty), update_modified=False)
-
- if not self.reserve_stock:
- continue
-
- warehouse = wip_warehouse or item.source_warehouse
- self.update_consumed_qty_in_stock_reservation(item, consumed_qty, warehouse)
-
- def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
- filters = {
- "voucher_no": self.name,
- "item_code": item.item_code,
- "voucher_detail_no": item.name,
- "warehouse": wip_warehouse,
- "docstatus": 1,
- }
-
- if not self.skip_transfer:
- filters["from_voucher_no"] = ("is", "set")
-
- row_wise_serial_batch = get_row_wise_serial_batch(self.name, "Manufacture")
-
- if names := frappe.get_all(
- "Stock Reservation Entry", filters=filters, pluck="name", order_by="creation"
- ):
- for name in names:
- if consumed_qty < 0:
- consumed_qty = 0
-
- doc = frappe.get_doc("Stock Reservation Entry", name)
- reserved_qty = doc.reserved_qty
- qty_to_update = consumed_qty if consumed_qty < reserved_qty else 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()
-
- def validate_reserved_qty(self):
- sre_details = get_sre_details(self.name)
- for item in self.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
- )
- )
-
@frappe.whitelist()
def make_bom(self):
data = frappe.db.sql(
@@ -1793,425 +898,93 @@ class WorkOrder(Document):
bom.set_bom_material_details()
return bom
- def set_reserved_qty_for_wip_and_fg(self, stock_entry):
- if stock_entry.is_return:
- return
+ def calculate_operating_cost(self):
+ return OperationsService(self).calculate_operating_cost()
- items = frappe._dict()
+ def set_work_order_operations(self):
+ return OperationsService(self).set_work_order_operations()
- stock_entry.reload()
- if stock_entry.purpose == "Manufacture" and (
- self.sales_order
- or self.production_plan_sub_assembly_item
- or self.subcontracting_inward_order
- or stock_entry.job_card
- ):
- items = self.get_finished_goods_for_reservation(stock_entry)
- elif stock_entry.purpose == "Material Transfer for Manufacture":
- items = self.get_list_of_materials_for_reservation(stock_entry)
+ def update_operation_status(self):
+ return OperationsService(self).update_operation_status()
- if not items:
- return
+ def set_actual_dates(self):
+ return OperationsService(self).set_actual_dates()
- item_list = list(items.values())
- make_stock_reservation_entries(self, item_list, is_transfer=False, notify=True)
+ def create_job_card_from_wo(self):
+ return OperationsService(self).create_job_card()
- 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.required_items}
+ def update_required_items(self, *args, **kwargs):
+ return RequiredItemsService(self).update_required_items(*args, **kwargs)
- for row in stock_entry.items:
- if row.item_code not in items:
- items[row.item_code] = frappe._dict(
- {
- "voucher_no": self.name,
- "voucher_type": self.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],
- }
- )
- 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)
+ def update_reserved_qty_for_production(self, *args, **kwargs):
+ return RequiredItemsService(self).update_reserved_qty_for_production(*args, **kwargs)
- return items
+ @frappe.whitelist()
+ def get_items_and_operations_from_bom(self, *args, **kwargs):
+ return RequiredItemsService(self).get_items_and_operations_from_bom(*args, **kwargs)
- def get_finished_goods_for_reservation(self, stock_entry):
- items = frappe._dict()
+ def set_available_qty(self, *args, **kwargs):
+ return RequiredItemsService(self).set_available_qty(*args, **kwargs)
- if self.production_plan_sub_assembly_item:
- # Reserve the sub-assembly item for the final product for the work order.
- item_details = self.get_wo_details()
- elif self.subcontracting_inward_order:
- item_details = self.get_scio_details()
- elif 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.production_item:
- return
+ def set_required_items(self, *args, **kwargs):
+ return RequiredItemsService(self).set_required_items(*args, **kwargs)
- item_details = self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
- else:
- # Reserve the final product for the sales order.
- item_details = self.get_so_details()
+ def update_transferred_qty_for_required_items(self, *args, **kwargs):
+ return RequiredItemsService(self).update_transferred_qty_for_required_items(*args, **kwargs)
- for item in item_details:
- qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty)
- if qty_to_reserve <= 0:
- continue
+ def update_returned_qty(self, *args, **kwargs):
+ return RequiredItemsService(self).update_returned_qty(*args, **kwargs)
- warehouse = item.warehouse
- if (
- item.get("parenttype") == "Work Order"
- and item.get("skip_transfer")
- and item.get("from_wip_warehouse")
- ):
- warehouse = item.wip_warehouse
+ def update_consumed_qty_for_required_items(self, *args, **kwargs):
+ return RequiredItemsService(self).update_consumed_qty_for_required_items(*args, **kwargs)
- for row in stock_entry.items:
- if (
- not row.t_warehouse
- or not row.is_finished_item
- or row.t_warehouse != warehouse
- or row.item_code != item.item_code
- ):
- continue
+ def remove_additional_items(self, *args, **kwargs):
+ return RequiredItemsService(self).remove_additional_items(*args, **kwargs)
- reserved_qty = qty_to_reserve
- if qty_to_reserve > row.transfer_qty:
- reserved_qty = row.transfer_qty
- qty_to_reserve -= row.transfer_qty
- else:
- qty_to_reserve = 0
+ def add_additional_items(self, *args, **kwargs):
+ return RequiredItemsService(self).add_additional_items(*args, **kwargs)
- if row.item_code not in items:
- items[row.item_code] = 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],
- }
- )
- else:
- items[row.item_code]["stock_qty"] += reserved_qty
+ def validate_work_order_against_so(self, *args, **kwargs):
+ return StatusService(self).validate_work_order_against_so(*args, **kwargs)
- return items
+ def update_status(self, *args, **kwargs):
+ return StatusService(self).update_status(*args, **kwargs)
- def get_items_to_reserve_for_job_card(self, stock_entry, finished_good):
- item_details = []
- for row in stock_entry.items:
- if row.item_code == finished_good:
- name = frappe.db.get_value(
- "Work Order Item",
- {"item_code": finished_good, "parent": self.name},
- "name",
- )
+ def get_status(self, *args, **kwargs):
+ return StatusService(self).get_status(*args, **kwargs)
- sres = frappe.get_all(
- "Stock Reservation Entry",
- fields=["reserved_qty"],
- filters={
- "voucher_no": self.name,
- "item_code": finished_good,
- "voucher_detail_no": name,
- "warehouse": row.t_warehouse,
- "docstatus": 1,
- "status": "Reserved",
- },
- )
+ def update_work_order_qty(self, *args, **kwargs):
+ return StatusService(self).update_work_order_qty(*args, **kwargs)
- pending_qty = row.qty
- for d in sres:
- pending_qty -= d.reserved_qty
+ def update_disassembled_qty(self, *args, **kwargs):
+ return StatusService(self).update_disassembled_qty(*args, **kwargs)
- if pending_qty > 0:
- item_details = [
- 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 get_transferred_or_manufactured_qty(self, *args, **kwargs):
+ return StatusService(self).get_transferred_or_manufactured_qty(*args, **kwargs)
- break
+ def set_process_loss_qty(self, *args, **kwargs):
+ return StatusService(self).set_process_loss_qty(*args, **kwargs)
- return item_details
+ def update_production_plan_status(self, *args, **kwargs):
+ return StatusService(self).update_production_plan_status(*args, **kwargs)
- def get_wo_details(self):
- doctype = frappe.qb.DocType("Work Order")
- child_doctype = frappe.qb.DocType("Work Order Item")
+ def update_planned_qty(self, *args, **kwargs):
+ return StatusService(self).update_planned_qty(*args, **kwargs)
- query = (
- frappe.qb.from_(doctype)
- .inner_join(child_doctype)
- .on(doctype.name == child_doctype.parent)
- .select(
- child_doctype.name,
- child_doctype.required_qty.as_("stock_qty"),
- child_doctype.transferred_qty.as_("delivered_qty"),
- child_doctype.stock_reserved_qty,
- child_doctype.source_warehouse.as_("warehouse"),
- doctype.wip_warehouse,
- doctype.skip_transfer,
- doctype.from_wip_warehouse,
- child_doctype.parenttype,
- child_doctype.item_code,
- child_doctype.parent.as_("voucher_no"),
- child_doctype.parenttype.as_("voucher_type"),
- )
- .where(
- (child_doctype.item_code == self.production_item)
- & (doctype.docstatus == 1)
- & (doctype.production_plan == self.production_plan)
- & (
- IfNull(doctype.production_plan_sub_assembly_item, "")
- != self.production_plan_sub_assembly_item
- )
- )
- )
+ def set_produced_qty_for_sub_assembly_item(self, *args, **kwargs):
+ return StatusService(self).set_produced_qty_for_sub_assembly_item(*args, **kwargs)
- return query.run(as_dict=1)
+ def update_ordered_qty(self, *args, **kwargs):
+ return StatusService(self).update_ordered_qty(*args, **kwargs)
- def get_scio_details(self):
- return frappe.get_all(
- "Subcontracting Inward Order Item",
- filters={
- "name": self.subcontracting_inward_order_item,
- "docstatus": 1,
- },
- 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",
- ],
- )
+ def update_work_order_qty_in_so(self, *args, **kwargs):
+ return StatusService(self).update_work_order_qty_in_so(*args, **kwargs)
- def get_so_details(self):
- return frappe.get_all(
- "Sales Order Item",
- filters={
- "parent": self.sales_order,
- "item_code": self.production_item,
- "docstatus": 1,
- },
- fields=[
- "item_code",
- "name",
- "stock_qty",
- "stock_reserved_qty",
- "warehouse",
- "parent as voucher_no",
- "parenttype as voucher_type",
- "delivered_qty",
- ],
- )
+ def update_work_order_qty_in_combined_so(self, *args, **kwargs):
+ return StatusService(self).update_work_order_qty_in_combined_so(*args, **kwargs)
- def get_voucher_details(self, stock_entry):
- vocher_detail_no = {}
-
- if stock_entry.purpose == "Manufacture" and self.sales_order:
- so_details = frappe.db.get_value(
- "Sales Order Item",
- {
- "parent": self.sales_order,
- "item_code": self.production_item,
- "docstatus": 1,
- "stock_reserved_qty": 0,
- },
- ["name", "stock_qty", "stock_reserved_qty"],
- as_dict=1,
- )
-
- vocher_detail_no = {self.production_item: so_details}
- else:
- vocher_detail_no = {d.item_code: d.name for d in self.required_items}
-
- return frappe._dict(vocher_detail_no)
-
- 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:
- cancel_stock_reservation_entries(self, sre_list)
-
- def remove_additional_items(self, stock_entry):
- for row in stock_entry.items:
- for item in self.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
-
- required_items = [d.item_code for d in self.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)
-
- self.flags.ignore_validate_update_after_submit = True
-
- for item_code, rows in additional_items.items():
- for row in rows:
- self.append(
- "required_items",
- {
- "item_code": 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,
- },
- )
-
- self.save()
- stock_entry.reload()
-
-
-@frappe.whitelist()
-def make_stock_reservation_entries(
- doc: str | Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False
-):
- is_transfer = cint(is_transfer)
- if isinstance(doc, str):
- doc = parse_json(doc)
- doc = frappe.get_doc("Work Order", doc.get("name"))
-
- 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:
- 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,
- )
- else:
- sre_created = sre.make_stock_reservation_entries()
- if sre_created:
- frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
-
- doc.reload()
- doc.db_set("status", doc.get_status())
-
-
-@frappe.whitelist()
-def cancel_stock_reservation_entries(doc: str | dict, sre_list: str | list):
- if isinstance(doc, str):
- doc = parse_json(doc)
- doc = frappe.get_doc("Work Order", doc.get("name"))
-
- 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:
- if row.voucher_detail_no not in sre_details:
- sre_details.setdefault(row.voucher_detail_no, row)
- else:
- sre_details[row.voucher_detail_no].reserved_qty += row.reserved_qty
- sre_details[row.voucher_detail_no].transferred_qty += row.transferred_qty
- sre_details[row.voucher_detail_no].consumed_qty += row.consumed_qty
-
- return sre_details
-
-
-def get_consumed_qty(work_order, item_code):
- stock_entry = frappe.qb.DocType("Stock Entry")
- stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
-
- query = (
- 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(
- (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))
- )
- )
-
- result = query.run()
-
- return flt(result[0][0]) if result else 0
+ def update_completed_qty_in_material_request(self, *args, **kwargs):
+ return StatusService(self).update_completed_qty_in_material_request(*args, **kwargs)
@frappe.whitelist()
@@ -2223,175 +996,6 @@ def get_bom_operations(doctype: str, txt: str, searchfield: str, start: int, pag
return frappe.get_all("BOM Operation", filters=filters, fields=["operation"], as_list=1)
-@frappe.whitelist()
-def get_item_details(item: str, project: str | None = None, skip_bom_info: bool = False, throw: bool = True):
- res = frappe.db.sql(
- """
- select stock_uom, description, item_name, allow_alternative_item,
- include_item_in_manufacturing
- from `tabItem`
- where disabled=0
- and (end_of_life is null or end_of_life='0000-00-00' or end_of_life > %s)
- and name=%s
- """,
- (nowdate(), item),
- as_dict=1,
- )
-
- if not res:
- return {}
-
- res = res[0]
- if skip_bom_info:
- return res
-
- filters = {"item": item, "is_default": 1, "docstatus": 1}
-
- if project:
- filters = {"item": item, "project": project}
-
- res["bom_no"] = frappe.db.get_value("BOM", filters=filters)
-
- if not res["bom_no"]:
- variant_of = frappe.db.get_value("Item", item, "variant_of")
-
- if variant_of:
- res["bom_no"] = frappe.db.get_value("BOM", filters={"item": variant_of, "is_default": 1})
-
- if not res["bom_no"]:
- 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
- )
- else:
- msg = _("Default BOM for {0} not found").format(item)
- frappe.msgprint(msg, raise_exception=throw, indicator="yellow", alert=(not throw))
-
- return res
-
- 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"]))
-
- return res
-
-
-@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,
-):
- from erpnext import get_default_company
-
- if not frappe.has_permission("Work Order", "write"):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
-
- item_details = get_item_details(item, project)
-
- if frappe.db.get_value("Item", item, "variant_of"):
- if variant_bom := frappe.db.get_value(
- "BOM",
- {"item": item, "is_default": 1, "docstatus": 1},
- ):
- bom_no = variant_bom
-
- 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)
-
- 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 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:
- 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"),
- }
- )
-
- bom_doc = frappe.get_cached_doc("BOM", bom_no)
- item_data = get_item_details(args.item_code, skip_bom_info=True)
- args.update(item_data)
-
- args["rate"] = 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,
- )
-
- if not args.source_warehouse:
- args["source_warehouse"] = get_item_defaults(
- item.get("variant_item_code"), wo_doc.company
- ).default_warehouse
-
- args["amount"] = flt(args.get("required_qty")) * flt(args.get("rate"))
- args["uom"] = item_data.stock_uom
-
- 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 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):
- res = {"set_scrap_wh_mandatory": False}
- if bom_no:
- bom = frappe.get_doc("BOM", bom_no)
-
- if bom.has_scrap_items():
- res["set_scrap_wh_mandatory"] = True
-
- return res
-
-
@frappe.whitelist()
def set_work_order_ops(name: str):
po = frappe.get_doc("Work Order", name)
@@ -2469,39 +1073,6 @@ def query_sales_order(doctype: str, txt: str, searchfield: str, start: int, page
)
-@frappe.whitelist()
-def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None):
- 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"),
- }
-
-
@frappe.whitelist()
def close_work_order(work_order: str, status: str):
if not frappe.has_permission("Work Order", "write"):
@@ -2528,239 +1099,3 @@ def close_work_order(work_order: str, status: str):
frappe.msgprint(_("Work Order has been {0}").format(status))
work_order.notify_update()
return work_order.status
-
-
-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")
- qty = row.job_card_qty or work_order.get("qty", 0)
- doc.update(
- {
- "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"),
- "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": work_order.wip_warehouse or row.get("wip_warehouse")
- if not work_order.skip_transfer or work_order.from_wip_warehouse
- else work_order.source_warehouse or row.get("source_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"),
- }
- )
-
- 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:
- 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)
-
- if enable_capacity_planning:
- # automatically added scheduling rows shouldn't change status to WIP
- doc.db_set("status", "Open")
-
- return doc
-
-
-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
-
-
-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")
-
- if check_production_plan:
- qty_field = wo_item.required_qty
- else:
- 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)
- qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
-
- 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)
- )
- )
-
- 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.run()[0][0] or 0.0
-
-
-def get_row_wise_serial_batch(work_order, purpose=None):
- if not purpose:
- purpose = "Material Transfer for Manufacture"
-
- stock_entries = frappe.get_all(
- "Stock Entry",
- filters={
- "work_order": work_order,
- "purpose": purpose,
- "docstatus": 1,
- },
- pluck="name",
- )
-
- serial_batch_entries = frappe.get_all(
- "Serial and Batch Bundle",
- 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`",
- ],
- 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],
- ],
- )
-
- row_wise_serial_batch = {}
- for entry in serial_batch_entries:
- key = (entry.item_code, entry.warehouse)
- if key not in row_wise_serial_batch:
- row_wise_serial_batch[key] = frappe._dict(
- {
- "serial_nos": [],
- "batch_nos": defaultdict(float),
- }
- )
-
- details = row_wise_serial_batch[key]
- if entry.serial_no:
- details.serial_nos.append(entry.serial_no)
- if entry.batch_no:
- details.batch_nos[entry.batch_no] += abs(entry.qty)
-
- return row_wise_serial_batch
-
-
-@frappe.request_cache
-def get_hour_rate(workstation):
- return frappe.get_cached_value("Workstation", workstation, "hour_rate") or 0.0
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 6f6bac58b0e..1574efa67e9 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -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 (