mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-06 21:59:13 +00:00
Merge pull request #55596 from rohitwaghchaure/refactor-manufacturing-related-files
refactor: split manufacturing related files into mapper + services modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
206
erpnext/manufacturing/doctype/bom/mapper.py
Normal file
206
erpnext/manufacturing/doctype/bom/mapper.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Document-mapping and query helpers for BOM (extracted from bom.py)."""
|
||||
|
||||
from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.version.version import get_diff
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Field
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.stock.doctype.item.item import get_item_details
|
||||
|
||||
_BOM_DIFF_IDENTIFIERS = {
|
||||
"operations": "operation",
|
||||
"items": "item_code",
|
||||
"secondary_items": "item_code",
|
||||
"exploded_items": "item_code",
|
||||
}
|
||||
|
||||
_VARIANT_BOM_MAPPING = {
|
||||
"BOM": {"doctype": "BOM", "validation": {"docstatus": ["=", 1]}},
|
||||
"BOM Item": {
|
||||
"doctype": "BOM Item",
|
||||
# stop get_mapped_doc copying parent bom_no to children
|
||||
"field_no_map": ["bom_no"],
|
||||
"condition": lambda doc: doc.has_variants == 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_children(parent: str | None = None, is_root: bool = False, **filters):
|
||||
frappe.has_permission("BOM", "read", throw=True)
|
||||
|
||||
if not parent or parent == "BOM":
|
||||
frappe.msgprint(_("Please select a BOM"))
|
||||
return
|
||||
|
||||
frappe.form_dict.parent = parent
|
||||
bom_doc = frappe.get_cached_doc("BOM", parent)
|
||||
frappe.has_permission("BOM", doc=bom_doc, throw=True)
|
||||
|
||||
bom_items = _bom_child_items(parent)
|
||||
_enrich_bom_items(bom_items, bom_doc)
|
||||
return bom_items
|
||||
|
||||
|
||||
def _bom_child_items(parent):
|
||||
return frappe.get_all(
|
||||
"BOM Item",
|
||||
fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"],
|
||||
filters=[["parent", "=", parent]],
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
|
||||
def _enrich_bom_items(bom_items, bom_doc):
|
||||
item_names = tuple(d.get("item_code") for d in bom_items)
|
||||
items = frappe.get_list(
|
||||
"Item",
|
||||
fields=["image", "description", "name", "stock_uom", "item_name", "is_sub_contracted_item"],
|
||||
filters=[["name", "in", item_names]],
|
||||
)
|
||||
for bom_item in bom_items:
|
||||
bom_item.update(next(item for item in items if item.get("name") == bom_item.get("item_code")))
|
||||
bom_item.parent_bom_qty = bom_doc.quantity
|
||||
bom_item.expandable = 0 if bom_item.value in ("", None) else 1
|
||||
bom_item.image = frappe.db.escape(bom_item.image)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_bom_diff(bom1: str, bom2: str):
|
||||
frappe.has_permission("BOM", "read", throw=True)
|
||||
if bom1 == bom2:
|
||||
frappe.throw(
|
||||
_("BOM 1 {0} and BOM 2 {1} should not be same").format(frappe.bold(bom1), frappe.bold(bom2))
|
||||
)
|
||||
|
||||
doc1 = frappe.get_doc("BOM", bom1)
|
||||
doc2 = frappe.get_doc("BOM", bom2)
|
||||
|
||||
out = get_diff(doc1, doc2)
|
||||
out.row_changed, out.added, out.removed = [], [], []
|
||||
for df in doc1.meta.fields:
|
||||
_diff_table_field(df, doc1, doc2, out)
|
||||
return out
|
||||
|
||||
|
||||
def _diff_table_field(df, doc1, doc2, out):
|
||||
from frappe.model import table_fields
|
||||
|
||||
if df.fieldtype not in table_fields:
|
||||
return
|
||||
|
||||
identifier = _BOM_DIFF_IDENTIFIERS[df.fieldname]
|
||||
old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname)
|
||||
old_map = {d.get(identifier): d for d in old_value}
|
||||
new_map = {d.get(identifier): d for d in new_value}
|
||||
|
||||
_collect_row_changes(df, identifier, old_map, new_value, out)
|
||||
for d in old_value:
|
||||
if d.get(identifier) not in new_map:
|
||||
out.removed.append([df.fieldname, d.as_dict()])
|
||||
|
||||
|
||||
def _collect_row_changes(df, identifier, old_map, new_value, out):
|
||||
for i, d in enumerate(new_value):
|
||||
if d.get(identifier) not in old_map:
|
||||
out.added.append([df.fieldname, d.as_dict()])
|
||||
continue
|
||||
|
||||
diff = get_diff(old_map[d.get(identifier)], d, for_child=True)
|
||||
if diff and diff.changed:
|
||||
out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def item_query(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
frappe.has_permission("Item", "read", throw=True)
|
||||
|
||||
searchfields = frappe.get_meta("Item", cached=True).get_search_fields()
|
||||
fields = ["name", "item_name", "item_group", "description"]
|
||||
fields.extend(f for f in searchfields if f not in ["name", "item_group", "description"])
|
||||
|
||||
query_filters = _item_query_filters(filters)
|
||||
or_filters = _item_query_or_filters(txt, searchfields or ["name"], query_filters)
|
||||
return frappe.get_list(
|
||||
"Item",
|
||||
fields=fields,
|
||||
filters=query_filters,
|
||||
or_filters=or_filters,
|
||||
order_by="idx desc, name, item_name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
|
||||
def _item_query_filters(filters):
|
||||
query_filters = [["disabled", "=", 0], [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()]]
|
||||
if filters and filters.get("item_code"):
|
||||
if not frappe.get_cached_value("Item", filters.get("item_code"), "has_variants"):
|
||||
query_filters.append(["has_variants", "=", 0])
|
||||
|
||||
for fieldname, value in (filters or {}).items():
|
||||
query_filters.append([fieldname, "=", value])
|
||||
return query_filters
|
||||
|
||||
|
||||
def _item_query_or_filters(txt, searchfields, query_filters):
|
||||
if not txt:
|
||||
return {}
|
||||
|
||||
or_filters = {s_field: ("like", f"%{txt}%") for s_field in searchfields}
|
||||
barcodes = frappe.get_all(
|
||||
"Item Barcode",
|
||||
fields=["parent as item_code"],
|
||||
filters={"barcode": ("like", f"%{txt}%")},
|
||||
distinct=True,
|
||||
)
|
||||
barcode_codes = [d.item_code for d in barcodes]
|
||||
if barcode_codes:
|
||||
or_filters["name"] = ("in", barcode_codes)
|
||||
return or_filters
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_variant_bom(
|
||||
source_name: str,
|
||||
bom_no: str,
|
||||
item: str,
|
||||
variant_items: str | list,
|
||||
target_doc: Document | str | None = None,
|
||||
):
|
||||
frappe.has_permission("BOM", "write", throw=True)
|
||||
|
||||
postprocess = partial(
|
||||
_postprocess_variant_bom, item=item, variant_items=variant_items, source_name=source_name
|
||||
)
|
||||
return get_mapped_doc("BOM", source_name, _VARIANT_BOM_MAPPING, target_doc, postprocess)
|
||||
|
||||
|
||||
def _postprocess_variant_bom(source, doc, item, variant_items, source_name):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item
|
||||
|
||||
item_data = get_item_details(item)
|
||||
doc.item = item
|
||||
doc.quantity = 1
|
||||
doc.update(
|
||||
{
|
||||
"item_name": item_data.item_name,
|
||||
"description": item_data.description,
|
||||
"uom": item_data.stock_uom,
|
||||
"allow_alternative_item": item_data.allow_alternative_item,
|
||||
}
|
||||
)
|
||||
add_variant_item(variant_items, doc, source_name)
|
||||
313
erpnext/manufacturing/doctype/bom/services/costing.py
Normal file
313
erpnext/manufacturing/doctype/bom/services/costing.py
Normal file
@@ -0,0 +1,313 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""BOM cost computation (extracted from bom.py).
|
||||
|
||||
``BOMCostingService`` wraps a BOM document (composition); bom.py keeps thin
|
||||
delegating stubs so external callers (bom_update_log, etc.) keep working.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
class BOMCostingService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_bom_currency(self, item):
|
||||
if (
|
||||
item.get("bom_no")
|
||||
and frappe.db.get_value("BOM", item.get("bom_no"), "currency") != self.doc.currency
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Currency of the BOM #{1} should be equal to the selected currency {2}").format(
|
||||
item.idx, item.bom_no, self.doc.currency
|
||||
)
|
||||
)
|
||||
|
||||
def get_rm_rate(self, arg, notify=True):
|
||||
"""Get raw material rate as per selected method, if bom exists takes bom cost"""
|
||||
if not self.doc.rm_cost_as_per:
|
||||
self.doc.rm_cost_as_per = "Valuation Rate"
|
||||
|
||||
rate = self._raw_material_rate(arg, notify) if arg else 0
|
||||
return flt(rate) * flt(self.doc.plc_conversion_rate or 1) / (self.doc.conversion_rate or 1)
|
||||
|
||||
def _raw_material_rate(self, arg, notify):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_item_rate
|
||||
|
||||
# Customer Provided parts and Supplier sourced parts will have zero rate
|
||||
if frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") or arg.get(
|
||||
"sourced_by_supplier"
|
||||
):
|
||||
return 0
|
||||
|
||||
if arg.get("bom_no") and (
|
||||
self.doc.set_rate_of_sub_assembly_item_based_on_bom or arg.get("is_phantom_item")
|
||||
):
|
||||
return flt(self.get_bom_unitcost(arg["bom_no"])) * (arg.get("conversion_factor") or 1)
|
||||
|
||||
rate = get_bom_item_rate(arg, self.doc)
|
||||
if not rate:
|
||||
self._warn_rate_not_found(arg, notify)
|
||||
return rate
|
||||
|
||||
def _warn_rate_not_found(self, arg, notify):
|
||||
if self.doc.rm_cost_as_per == "Price List":
|
||||
msg = _("Price not found for item {0} in price list {1}").format(
|
||||
arg["item_code"], self.doc.buying_price_list
|
||||
)
|
||||
elif notify:
|
||||
msg = _("{0} not found for item {1}").format(self.doc.rm_cost_as_per, arg["item_code"])
|
||||
else:
|
||||
return
|
||||
frappe.msgprint(msg, alert=True)
|
||||
|
||||
def update_cost(
|
||||
self,
|
||||
update_parent: bool = True,
|
||||
from_child_bom: bool = False,
|
||||
update_hour_rate: bool = True,
|
||||
save: bool = True,
|
||||
):
|
||||
if self.doc.docstatus == 2:
|
||||
return
|
||||
|
||||
self.doc.flags.cost_updated = False
|
||||
existing_bom_cost = self.doc.total_cost
|
||||
|
||||
if self.doc.docstatus == 1:
|
||||
self.doc.flags.ignore_validate_update_after_submit = True
|
||||
|
||||
self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
|
||||
|
||||
if save:
|
||||
self.doc.db_update()
|
||||
|
||||
if self.doc.total_cost != existing_bom_cost and update_parent:
|
||||
self._update_parent_boms()
|
||||
|
||||
if not from_child_bom:
|
||||
msg = "Cost Updated" if self.doc.flags.cost_updated else "No changes in cost found"
|
||||
frappe.msgprint(_(msg), alert=True)
|
||||
|
||||
def _update_parent_boms(self):
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
parent_boms = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.select(bom_item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(bom_item.bom_no == self.doc.name)
|
||||
& (bom_item.docstatus == 1)
|
||||
& (bom_item.parenttype == "BOM")
|
||||
)
|
||||
).run(pluck=True)
|
||||
|
||||
for bom in parent_boms:
|
||||
frappe.get_doc("BOM", bom).update_cost(from_child_bom=True)
|
||||
|
||||
def update_parent_cost(self):
|
||||
if self.doc.total_cost:
|
||||
cost = self.doc.total_cost / self.doc.quantity
|
||||
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
(
|
||||
frappe.qb.update(bom_item)
|
||||
.set(bom_item.rate, cost)
|
||||
.set(bom_item.amount, bom_item.stock_qty * cost)
|
||||
.where(
|
||||
(bom_item.bom_no == self.doc.name)
|
||||
& (bom_item.docstatus < 2)
|
||||
& (bom_item.parenttype == "BOM")
|
||||
)
|
||||
).run()
|
||||
|
||||
def get_bom_unitcost(self, bom_no):
|
||||
bom_table = frappe.qb.DocType("BOM")
|
||||
bom = (
|
||||
frappe.qb.from_(bom_table)
|
||||
.select(
|
||||
bom_table.name,
|
||||
(bom_table.base_total_cost / bom_table.quantity).as_("unit_cost"),
|
||||
)
|
||||
.where((bom_table.is_active == 1) & (bom_table.name == bom_no))
|
||||
).run(as_dict=1)
|
||||
return bom and bom[0]["unit_cost"] or 0
|
||||
|
||||
def calculate_cost(self, save_updates=False, update_hour_rate=False):
|
||||
"""Calculate bom totals"""
|
||||
self.calculate_op_cost(update_hour_rate)
|
||||
self.calculate_rm_cost(save=save_updates)
|
||||
self.calculate_secondary_items_costs(save=save_updates)
|
||||
if save_updates:
|
||||
# not via doc event, table is not regenerated and needs updation
|
||||
self.calculate_exploded_cost()
|
||||
|
||||
old_cost = self.doc.total_cost
|
||||
|
||||
self.doc.total_cost = (
|
||||
self.doc.operating_cost + self.doc.raw_material_cost - self.doc.secondary_items_cost
|
||||
)
|
||||
self.doc.base_total_cost = (
|
||||
self.doc.base_operating_cost
|
||||
+ self.doc.base_raw_material_cost
|
||||
- self.doc.base_secondary_items_cost
|
||||
)
|
||||
|
||||
if self.doc.total_cost != old_cost:
|
||||
self.doc.flags.cost_updated = True
|
||||
|
||||
def calculate_op_cost(self, update_hour_rate=False):
|
||||
"""Update workstation rate and calculates totals"""
|
||||
self.doc.operating_cost = 0
|
||||
self.doc.base_operating_cost = 0
|
||||
if self.doc.get("with_operations"):
|
||||
for d in self.doc.get("operations"):
|
||||
self._accumulate_operation_cost(d, update_hour_rate)
|
||||
elif self.doc.get("fg_based_operating_cost"):
|
||||
self._set_fg_based_operating_cost()
|
||||
|
||||
def _accumulate_operation_cost(self, d, update_hour_rate):
|
||||
if d.workstation or d.workstation_type:
|
||||
self.update_rate_and_time(d, update_hour_rate)
|
||||
|
||||
operating_cost = d.operating_cost
|
||||
base_operating_cost = d.base_operating_cost
|
||||
if d.set_cost_based_on_bom_qty:
|
||||
operating_cost = flt(d.cost_per_unit) * flt(self.doc.quantity)
|
||||
base_operating_cost = flt(d.base_cost_per_unit) * flt(self.doc.quantity)
|
||||
|
||||
self.doc.operating_cost += flt(operating_cost)
|
||||
self.doc.base_operating_cost += flt(base_operating_cost)
|
||||
|
||||
def _set_fg_based_operating_cost(self):
|
||||
total = flt(self.doc.get("quantity")) * flt(self.doc.get("operating_cost_per_bom_quantity"))
|
||||
self.doc.operating_cost = total
|
||||
self.doc.base_operating_cost = flt(total * self.doc.conversion_rate, 2)
|
||||
|
||||
def update_rate_and_time(self, row, update_hour_rate=False):
|
||||
if not row.hour_rate or update_hour_rate:
|
||||
self._set_row_hour_rate(row)
|
||||
|
||||
if row.hour_rate:
|
||||
row.base_hour_rate = flt(row.hour_rate) * flt(self.doc.conversion_rate)
|
||||
if row.time_in_mins:
|
||||
self._set_row_operating_costs(row)
|
||||
|
||||
if update_hour_rate:
|
||||
row.db_update()
|
||||
|
||||
def _set_row_hour_rate(self, row):
|
||||
hour_rate = 0
|
||||
if row.workstation:
|
||||
hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate"))
|
||||
elif row.workstation_type:
|
||||
hour_rate = flt(frappe.get_cached_value("Workstation Type", row.workstation_type, "hour_rate"))
|
||||
|
||||
if hour_rate:
|
||||
row.hour_rate = (
|
||||
hour_rate / flt(self.doc.conversion_rate) if self.doc.conversion_rate else hour_rate
|
||||
)
|
||||
|
||||
def _set_row_operating_costs(self, row):
|
||||
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
|
||||
row.base_operating_cost = flt(row.operating_cost) * flt(self.doc.conversion_rate)
|
||||
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
|
||||
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
|
||||
|
||||
def calculate_rm_cost(self, save=False):
|
||||
"""Fetch RM rate as per today's valuation rate and calculate totals"""
|
||||
total_rm_cost = 0
|
||||
base_total_rm_cost = 0
|
||||
|
||||
for d in self.doc.get("items"):
|
||||
old_rate = d.rate
|
||||
if not self.doc.bom_creator and (d.is_stock_item or d.is_phantom_item):
|
||||
d.rate = self.get_rm_rate(self._rm_rate_args(d), notify=False)
|
||||
|
||||
self._set_item_amounts(d)
|
||||
total_rm_cost += d.amount
|
||||
base_total_rm_cost += d.base_amount
|
||||
if save and (old_rate != d.rate):
|
||||
d.db_update()
|
||||
|
||||
self.doc.raw_material_cost = total_rm_cost
|
||||
self.doc.base_raw_material_cost = base_total_rm_cost
|
||||
|
||||
def _rm_rate_args(self, d):
|
||||
return {
|
||||
"company": self.doc.company,
|
||||
"item_code": d.item_code,
|
||||
"bom_no": d.bom_no,
|
||||
"qty": d.qty,
|
||||
"uom": d.uom,
|
||||
"stock_uom": d.stock_uom,
|
||||
"conversion_factor": d.conversion_factor,
|
||||
"sourced_by_supplier": d.sourced_by_supplier,
|
||||
"is_phantom_item": d.is_phantom_item,
|
||||
}
|
||||
|
||||
def _set_item_amounts(self, d):
|
||||
d.base_rate = flt(d.rate) * flt(self.doc.conversion_rate)
|
||||
d.amount = flt(
|
||||
flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty")), d.precision("amount")
|
||||
)
|
||||
d.base_amount = d.amount * flt(self.doc.conversion_rate)
|
||||
d.qty_consumed_per_unit = flt(d.stock_qty, d.precision("stock_qty")) / flt(
|
||||
self.doc.quantity, self.doc.precision("quantity")
|
||||
)
|
||||
|
||||
def calculate_secondary_items_costs(self, save=False):
|
||||
"""Fetch RM rate as per today's valuation rate and calculate totals"""
|
||||
total_sm_cost = 0
|
||||
base_total_sm_cost = 0
|
||||
precision = self.doc.precision("raw_material_cost")
|
||||
|
||||
for d in self.doc.get("secondary_items"):
|
||||
if not d.is_legacy:
|
||||
d.cost = flt(self.doc.raw_material_cost * (d.cost_allocation_per / 100), precision)
|
||||
d.base_cost = flt(d.cost * self.doc.conversion_rate, precision)
|
||||
|
||||
total_sm_cost += d.cost
|
||||
base_total_sm_cost += d.base_cost
|
||||
if save:
|
||||
d.db_update()
|
||||
|
||||
self.doc.secondary_items_cost = total_sm_cost
|
||||
self.doc.base_secondary_items_cost = base_total_sm_cost
|
||||
|
||||
def calculate_exploded_cost(self):
|
||||
"Set exploded row cost from it's parent BOM."
|
||||
rm_rate_map = self.get_rm_rate_map()
|
||||
|
||||
for row in self.doc.get("exploded_items"):
|
||||
old_rate = flt(row.rate)
|
||||
row.rate = rm_rate_map.get(row.item_code)
|
||||
row.amount = flt(row.stock_qty) * flt(row.rate)
|
||||
|
||||
if old_rate != row.rate:
|
||||
# Only db_update if changed
|
||||
row.db_update()
|
||||
|
||||
def get_rm_rate_map(self) -> dict[str, float]:
|
||||
"Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
|
||||
rm_rate_map = {}
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if item.bom_no:
|
||||
# Get Item-Rate from Subassembly BOM
|
||||
explosion_items = frappe.get_all(
|
||||
"BOM Explosion Item",
|
||||
filters={"parent": item.bom_no},
|
||||
fields=["item_code", "rate"],
|
||||
order_by=None, # to avoid sort index creation at db level (granular change)
|
||||
)
|
||||
explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
|
||||
rm_rate_map.update(explosion_item_rate)
|
||||
else:
|
||||
rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)
|
||||
|
||||
return rm_rate_map
|
||||
127
erpnext/manufacturing/doctype/bom/services/exploded_items.py
Normal file
127
erpnext/manufacturing/doctype/bom/services/exploded_items.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""BOM exploded-items (flat BOM) computation (extracted from bom.py)."""
|
||||
|
||||
from operator import itemgetter
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
class BOMExplodedItemsService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def update_exploded_items(self, save=True):
|
||||
"""Update Flat BOM, following will be correct data"""
|
||||
self.get_exploded_items()
|
||||
self.add_exploded_items(save=save)
|
||||
|
||||
def get_exploded_items(self):
|
||||
"""Get all raw materials including items from child bom"""
|
||||
self.doc.cur_exploded_items = {}
|
||||
for d in self.doc.get("items"):
|
||||
if d.bom_no:
|
||||
self.get_child_exploded_items(d.bom_no, d.stock_qty, d.operation)
|
||||
elif d.item_code:
|
||||
self.add_to_cur_exploded_items(self._exploded_item_row(d))
|
||||
|
||||
@staticmethod
|
||||
def _exploded_item_row(d):
|
||||
return frappe._dict(
|
||||
{
|
||||
"item_code": d.item_code,
|
||||
"item_name": d.item_name,
|
||||
"operation": d.operation,
|
||||
"is_sub_assembly_item": d.is_sub_assembly_item,
|
||||
"source_warehouse": d.source_warehouse,
|
||||
"description": d.description,
|
||||
"image": d.image,
|
||||
"stock_uom": d.stock_uom,
|
||||
"stock_qty": flt(d.stock_qty),
|
||||
"rate": flt(d.base_rate) / (flt(d.conversion_factor) or 1.0),
|
||||
"include_item_in_manufacturing": d.include_item_in_manufacturing,
|
||||
"sourced_by_supplier": d.sourced_by_supplier,
|
||||
}
|
||||
)
|
||||
|
||||
def add_to_cur_exploded_items(self, args):
|
||||
key = args.item_code
|
||||
if args.operation:
|
||||
key = (args.item_code, args.operation)
|
||||
|
||||
if self.doc.cur_exploded_items.get(key):
|
||||
self.doc.cur_exploded_items[key]["stock_qty"] += args.stock_qty
|
||||
else:
|
||||
self.doc.cur_exploded_items[key] = args
|
||||
|
||||
def get_child_exploded_items(self, bom_no, stock_qty, operation=None):
|
||||
"""Add all items from Flat BOM of child BOM"""
|
||||
for d in self._fetch_child_flat_bom_items(bom_no):
|
||||
self.add_to_cur_exploded_items(self._child_exploded_row(d, stock_qty, operation))
|
||||
|
||||
@staticmethod
|
||||
def _fetch_child_flat_bom_items(bom_no):
|
||||
# Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
|
||||
bom_item = frappe.qb.DocType("BOM Explosion Item")
|
||||
bom = frappe.qb.DocType("BOM")
|
||||
qty_consumed_per_unit = (bom_item.stock_qty / IfNull(bom.quantity, 1)).as_("qty_consumed_per_unit")
|
||||
return (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(bom)
|
||||
.on(bom_item.parent == bom.name)
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
bom_item.item_name,
|
||||
bom_item.description,
|
||||
bom_item.source_warehouse,
|
||||
bom_item.operation,
|
||||
bom_item.is_sub_assembly_item,
|
||||
bom_item.stock_uom,
|
||||
bom_item.stock_qty,
|
||||
bom_item.rate,
|
||||
bom_item.include_item_in_manufacturing,
|
||||
bom_item.sourced_by_supplier,
|
||||
qty_consumed_per_unit,
|
||||
)
|
||||
.where((bom.name == bom_no) & (bom.docstatus == 1))
|
||||
).run(as_dict=1)
|
||||
|
||||
@staticmethod
|
||||
def _child_exploded_row(d, stock_qty, operation):
|
||||
return frappe._dict(
|
||||
{
|
||||
"item_code": d["item_code"],
|
||||
"item_name": d["item_name"],
|
||||
"source_warehouse": d["source_warehouse"],
|
||||
"operation": d["operation"] or operation,
|
||||
"description": d["description"],
|
||||
"stock_uom": d["stock_uom"],
|
||||
"stock_qty": d["qty_consumed_per_unit"] * stock_qty,
|
||||
"rate": flt(d["rate"]),
|
||||
"include_item_in_manufacturing": d.get("include_item_in_manufacturing", 0),
|
||||
"sourced_by_supplier": d.get("sourced_by_supplier", 0),
|
||||
"is_sub_assembly_item": d.get("is_sub_assembly_item", 0),
|
||||
}
|
||||
)
|
||||
|
||||
def add_exploded_items(self, save=True):
|
||||
"Add items to Flat BOM table"
|
||||
self.doc.set("exploded_items", [])
|
||||
|
||||
if save:
|
||||
explosion_item = frappe.qb.DocType("BOM Explosion Item")
|
||||
frappe.qb.from_(explosion_item).delete().where(explosion_item.parent == self.doc.name).run()
|
||||
|
||||
for d in sorted(self.doc.cur_exploded_items, key=itemgetter(0)):
|
||||
ch = self.doc.append("exploded_items", {})
|
||||
for i in self.doc.cur_exploded_items[d].keys():
|
||||
ch.set(i, self.doc.cur_exploded_items[d][i])
|
||||
ch.amount = flt(ch.stock_qty) * flt(ch.rate)
|
||||
ch.qty_consumed_per_unit = flt(ch.stock_qty) / flt(self.doc.quantity)
|
||||
ch.docstatus = self.doc.docstatus
|
||||
|
||||
if save:
|
||||
ch.db_insert()
|
||||
305
erpnext/manufacturing/doctype/bom/services/operations_cost.py
Normal file
305
erpnext/manufacturing/doctype/bom/services/operations_cost.py
Normal file
@@ -0,0 +1,305 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Operating-cost helpers applied to Stock Entry / Job Card from a BOM.
|
||||
|
||||
Extracted from bom.py; bom.py re-exports them for backward compatibility.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Field
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
|
||||
def add_additional_cost(stock_entry, work_order, job_card=None):
|
||||
# Add non stock items cost in the additional cost
|
||||
stock_entry.additional_costs = []
|
||||
expense_account = frappe.get_value(
|
||||
"Company",
|
||||
work_order.company,
|
||||
"default_operating_cost_account",
|
||||
)
|
||||
add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=job_card)
|
||||
add_operations_cost(stock_entry, work_order, expense_account, job_card=job_card)
|
||||
|
||||
|
||||
def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=None):
|
||||
bom = frappe.get_doc("BOM", work_order.bom_no)
|
||||
item_amounts = _non_phantom_item_amounts(bom, _bom_items_table(work_order, job_card))
|
||||
cost = _non_stock_items_cost(item_amounts, stock_entry, bom)
|
||||
|
||||
if cost:
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{"expense_account": expense_account, "description": _("Non stock items"), "amount": cost},
|
||||
)
|
||||
|
||||
|
||||
def _bom_items_table(work_order, job_card):
|
||||
if work_order and not job_card:
|
||||
return "exploded_items" if work_order.get("use_multi_level_bom") else "items"
|
||||
return "items"
|
||||
|
||||
|
||||
def _non_phantom_item_amounts(bom, table):
|
||||
items = frappe._dict()
|
||||
for d in bom.get(table):
|
||||
# Phantom item is exploded, so its cost is considered via its components
|
||||
if d.get("is_phantom_item"):
|
||||
continue
|
||||
|
||||
items.setdefault(d.item_code, 0)
|
||||
items[d.item_code] += flt(d.amount)
|
||||
return items
|
||||
|
||||
|
||||
def _non_stock_items_cost(item_amounts, stock_entry, bom):
|
||||
non_stock_items = frappe.get_all(
|
||||
"Item",
|
||||
fields="name",
|
||||
filters=[["name", "in", list(item_amounts.keys())], [IfNull(Field("is_stock_item"), 0), "=", 0]],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
cost = 0.0
|
||||
for name in non_stock_items:
|
||||
cost += flt(item_amounts.get(name[0])) * flt(stock_entry.fg_completed_qty) / flt(bom.quantity)
|
||||
return cost
|
||||
|
||||
|
||||
def add_operating_cost_component_wise(stock_entry, work_order=None, op_expense_account=None, job_card=None):
|
||||
if not work_order:
|
||||
return False
|
||||
|
||||
cost_added = False
|
||||
for row in work_order.operations:
|
||||
if job_card and job_card.operation_id != row.name:
|
||||
continue
|
||||
if not row.actual_operation_time:
|
||||
continue
|
||||
if _add_operation_workstation_costs(stock_entry, work_order, row, op_expense_account):
|
||||
cost_added = True
|
||||
|
||||
return cost_added
|
||||
|
||||
|
||||
def _add_operation_workstation_costs(stock_entry, work_order, row, op_expense_account):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_consumed_operating_cost
|
||||
|
||||
workstation_cost = frappe.get_all(
|
||||
"Workstation Cost",
|
||||
fields=["operating_component", "operating_cost"],
|
||||
filters={"parent": row.workstation, "parenttype": "Workstation"},
|
||||
)
|
||||
consumed = get_consumed_operating_cost(work_order.name, stock_entry.bom_no, row.name) or []
|
||||
|
||||
cost_added = False
|
||||
for wc in workstation_cost:
|
||||
if _append_workstation_cost(stock_entry, row, wc, consumed, op_expense_account):
|
||||
cost_added = True
|
||||
return cost_added
|
||||
|
||||
|
||||
def _append_workstation_cost(stock_entry, row, wc, consumed, op_expense_account):
|
||||
expense_account = get_component_account(wc.operating_component, stock_entry.company) or op_expense_account
|
||||
consumed_op_cost = next(
|
||||
(c for c in consumed if c.get("operating_component") == wc.operating_component), {}
|
||||
)
|
||||
actual = _actual_operating_cost(wc, row, consumed_op_cost)
|
||||
if not actual:
|
||||
return False
|
||||
|
||||
remaining_qty = row.completed_qty - consumed_op_cost.get("consumed_qty", 0)
|
||||
operating_cost = (actual / (remaining_qty or 1)) * stock_entry.fg_completed_qty
|
||||
qty = min(remaining_qty, stock_entry.fg_completed_qty)
|
||||
row_data = _workstation_cost_row(expense_account, row, wc, actual, operating_cost, qty)
|
||||
stock_entry.append("additional_costs", row_data)
|
||||
return True
|
||||
|
||||
|
||||
def _actual_operating_cost(wc, row, consumed_op_cost):
|
||||
return flt(
|
||||
flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0)
|
||||
- flt(consumed_op_cost.get("consumed_cost")),
|
||||
row.precision("actual_operating_cost"),
|
||||
)
|
||||
|
||||
|
||||
def _workstation_cost_row(expense_account, row, wc, actual, operating_cost, qty):
|
||||
precision = frappe.get_precision("Landed Cost Taxes and Charges", "amount")
|
||||
return {
|
||||
"expense_account": expense_account,
|
||||
"description": _("{0} Operating Cost for operation {1}").format(
|
||||
wc.operating_component, row.operation
|
||||
),
|
||||
"amount": flt(min(operating_cost, actual), precision),
|
||||
"has_operating_cost": 1,
|
||||
"operation_id": row.name,
|
||||
"operating_component": wc.operating_component,
|
||||
"qty": qty,
|
||||
}
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_component_account(parent, company):
|
||||
return frappe.db.get_value(
|
||||
"Workstation Operating Component Account", {"parent": parent, "company": company}, "expense_account"
|
||||
)
|
||||
|
||||
|
||||
def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_remaining_operating_cost
|
||||
|
||||
remaining_operating_cost = get_remaining_operating_cost(work_order, stock_entry.bom_no)
|
||||
if remaining_operating_cost:
|
||||
_add_remaining_operating_cost(
|
||||
stock_entry, work_order, expense_account, job_card, remaining_operating_cost
|
||||
)
|
||||
|
||||
_add_additional_operating_cost(stock_entry, work_order, expense_account)
|
||||
_add_corrective_operation_cost(stock_entry, work_order, expense_account)
|
||||
|
||||
|
||||
def _add_remaining_operating_cost(stock_entry, work_order, expense_account, job_card, remaining_cost):
|
||||
if add_operating_cost_component_wise(stock_entry, work_order, expense_account, job_card=job_card):
|
||||
return
|
||||
if job_card:
|
||||
return
|
||||
|
||||
precision = frappe.get_precision("Landed Cost Taxes and Charges", "amount")
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
"expense_account": expense_account,
|
||||
"description": _("Operating Cost as per Work Order / BOM"),
|
||||
"amount": flt(remaining_cost * stock_entry.fg_completed_qty, precision),
|
||||
"has_operating_cost": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _add_additional_operating_cost(stock_entry, work_order, expense_account):
|
||||
if not (work_order and work_order.additional_operating_cost and work_order.qty):
|
||||
return
|
||||
|
||||
per_unit = flt(work_order.additional_operating_cost) / flt(work_order.qty)
|
||||
if not per_unit:
|
||||
return
|
||||
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
"expense_account": expense_account,
|
||||
"description": "Additional Operating Cost",
|
||||
"amount": per_unit * flt(stock_entry.fg_completed_qty),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _add_corrective_operation_cost(stock_entry, work_order, expense_account):
|
||||
if not (work_order and work_order.corrective_operation_cost and _corrective_cost_enabled()):
|
||||
return
|
||||
|
||||
max_qty = _max_operation_quantity(work_order) - work_order.produced_qty
|
||||
remaining = work_order.corrective_operation_cost - _utilised_corrective_cost(work_order)
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
"expense_account": expense_account,
|
||||
"description": "Corrective Operation Cost",
|
||||
"has_corrective_cost": 1,
|
||||
"amount": remaining / max_qty * flt(stock_entry.fg_completed_qty),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _max_operation_quantity(work_order):
|
||||
table = frappe.qb.DocType("Job Card")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.total_completed_qty).as_("qty"))
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (table.work_order == work_order.name)
|
||||
& (table.is_corrective_job_card == 0)
|
||||
)
|
||||
.groupby(table.operation)
|
||||
)
|
||||
return min([d.qty for d in query.run(as_dict=True)], default=0)
|
||||
|
||||
|
||||
def _corrective_cost_enabled():
|
||||
return cint(
|
||||
frappe.db.get_single_value(
|
||||
"Manufacturing Settings", "add_corrective_operation_cost_in_finished_good_valuation"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _utilised_corrective_cost(work_order):
|
||||
charges = frappe.qb.DocType("Landed Cost Taxes and Charges")
|
||||
query = (
|
||||
frappe.qb.from_(charges)
|
||||
.select(Sum(charges.amount).as_("amount"))
|
||||
.where(
|
||||
charges.parent.isin(_manufacture_stock_entries(work_order)) & (charges.has_corrective_cost == 1)
|
||||
)
|
||||
)
|
||||
return query.run(as_dict=True)[0].amount or 0
|
||||
|
||||
|
||||
def _manufacture_stock_entries(work_order):
|
||||
stock_entry = frappe.qb.DocType("Stock Entry")
|
||||
return (
|
||||
frappe.qb.from_(stock_entry)
|
||||
.select(stock_entry.name)
|
||||
.where(
|
||||
(stock_entry.docstatus == 1)
|
||||
& (stock_entry.work_order == work_order.name)
|
||||
& (stock_entry.purpose == "Manufacture")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_op_cost_from_sub_assemblies(bom_no, op_cost=0):
|
||||
# Get operating cost from sub-assemblies
|
||||
|
||||
bom_items = frappe.get_all(
|
||||
"BOM Item", filters={"parent": bom_no, "docstatus": 1}, fields=["bom_no"], order_by="idx asc"
|
||||
)
|
||||
|
||||
for row in bom_items:
|
||||
if not row.bom_no:
|
||||
continue
|
||||
|
||||
if cost := frappe.get_cached_value("BOM", row.bom_no, "operating_cost_per_bom_quantity"):
|
||||
op_cost += flt(cost)
|
||||
get_op_cost_from_sub_assemblies(row.bom_no, op_cost)
|
||||
|
||||
return op_cost
|
||||
|
||||
|
||||
def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
|
||||
if not secondary_items:
|
||||
secondary_items = {}
|
||||
|
||||
for row in _child_bom_items_with_qty(bom_no):
|
||||
if not row.bom_no:
|
||||
continue
|
||||
|
||||
qty = flt(row.qty) * flt(qty)
|
||||
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
|
||||
secondary_items.update(items)
|
||||
get_secondary_items_from_sub_assemblies(row.bom_no, company, qty, secondary_items)
|
||||
|
||||
return secondary_items
|
||||
|
||||
|
||||
def _child_bom_items_with_qty(bom_no):
|
||||
return frappe.get_all(
|
||||
"BOM Item", filters={"parent": bom_no, "docstatus": 1}, fields=["bom_no", "qty"], order_by="idx asc"
|
||||
)
|
||||
64
erpnext/manufacturing/doctype/production_plan/mapper.py
Normal file
64
erpnext/manufacturing/doctype/production_plan/mapper.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""Query/data helpers for Production Plan (extracted from production_plan.py)."""
|
||||
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_so_details(sales_order: str):
|
||||
frappe.has_permission("Sales Order", "read", throw=True)
|
||||
|
||||
return frappe.db.get_value(
|
||||
"Sales Order", sales_order, ["transaction_date", "customer", "grand_total"], as_dict=1
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def sales_order_query(
|
||||
doctype: str | None = None,
|
||||
txt: str | None = None,
|
||||
searchfield: str | None = None,
|
||||
start: int | None = None,
|
||||
page_len: int | None = None,
|
||||
filters: dict | None = None,
|
||||
):
|
||||
frappe.has_permission("Production Plan", throw=True)
|
||||
|
||||
filters = filters or {}
|
||||
so_table = frappe.qb.DocType("Sales Order")
|
||||
table = frappe.qb.DocType("Sales Order Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(so_table)
|
||||
.join(table)
|
||||
.on(table.parent == so_table.name)
|
||||
.select(table.parent)
|
||||
.distinct()
|
||||
.where((table.qty > table.production_plan_qty) & (table.docstatus == 1))
|
||||
)
|
||||
query = _apply_sales_order_filters(query, so_table, table, filters, txt)
|
||||
query = _paginate(query, start, page_len)
|
||||
return query.run()
|
||||
|
||||
|
||||
def _paginate(query, start, page_len):
|
||||
if page_len:
|
||||
query = query.limit(page_len)
|
||||
if start:
|
||||
query = query.offset(start)
|
||||
return query
|
||||
|
||||
|
||||
def _apply_sales_order_filters(query, so_table, table, filters, txt):
|
||||
if filters.get("company"):
|
||||
query = query.where(so_table.company == filters.get("company"))
|
||||
if filters.get("sales_orders"):
|
||||
query = query.where(so_table.name.isin(filters.get("sales_orders")))
|
||||
if filters.get("item_code"):
|
||||
query = query.where(table.item_code == filters.get("item_code"))
|
||||
if txt:
|
||||
query = query.where(table.parent.like(f"%{txt}%"))
|
||||
return query
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,667 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""Material Request planning and creation for a Production Plan.
|
||||
|
||||
Consolidates the former ``material_planning``, ``material_request_items`` and
|
||||
``material_request_helpers`` modules. Also re-exports the planning helpers so
|
||||
existing imports of ``...services.material_planning`` keep working through here.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, ceil, cint, comma_and, flt, get_link_to_form, nowdate
|
||||
from frappe.utils.csvutils import build_csv_response
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.services.bom_explosion import (
|
||||
get_exploded_items,
|
||||
get_subitems,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.production_plan.services.planning_helpers import (
|
||||
get_bin_details,
|
||||
get_item_data,
|
||||
get_sales_orders,
|
||||
get_uom_conversion_factor,
|
||||
get_warehouse_list,
|
||||
set_default_warehouses,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_planning import (
|
||||
get_raw_materials_of_sub_assembly_items,
|
||||
get_sub_assembly_items,
|
||||
)
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.get_item_details import get_conversion_factor
|
||||
|
||||
|
||||
class MaterialRequestService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_mr_subcontracted(self):
|
||||
for row in self.doc.mr_items:
|
||||
if row.material_request_type != "Subcontracting":
|
||||
continue
|
||||
if not frappe.db.get_value("Item", row.item_code, "is_sub_contracted_item"):
|
||||
frappe.throw(
|
||||
_("Item {0} is not a subcontracted item").format(row.item_code),
|
||||
title=_("Invalid Item"),
|
||||
)
|
||||
|
||||
def make_material_request(self):
|
||||
"""Create Material Requests grouped by Sales Order and Material Request Type"""
|
||||
self.validate_mr_subcontracted()
|
||||
|
||||
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
|
||||
msgprint(_("All items are already requested"))
|
||||
return
|
||||
|
||||
material_request_map = {}
|
||||
material_request_list = []
|
||||
for item in self.doc.mr_items:
|
||||
if item.quantity == item.requested_qty:
|
||||
continue
|
||||
self._add_item_to_material_request(item, material_request_map, material_request_list)
|
||||
|
||||
self._submit_material_requests(material_request_list)
|
||||
|
||||
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
material_request_type = item.material_request_type or item_doc.default_material_request_type
|
||||
|
||||
# key for Sales Order:Material Request Type:Customer
|
||||
key = "{}:{}:{}".format(item.sales_order, material_request_type, "")
|
||||
if key not in material_request_map:
|
||||
material_request_map[key] = self._new_material_request(material_request_type)
|
||||
material_request_list.append(material_request_map[key])
|
||||
|
||||
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
|
||||
row = self._material_request_item(item, material_request_type, schedule_date)
|
||||
material_request_map[key].append("items", row)
|
||||
|
||||
def _new_material_request(self, material_request_type):
|
||||
mr = frappe.new_doc("Material Request")
|
||||
mr.update(
|
||||
{
|
||||
"transaction_date": nowdate(),
|
||||
"status": "Draft",
|
||||
"company": self.doc.company,
|
||||
"material_request_type": material_request_type,
|
||||
}
|
||||
)
|
||||
return mr
|
||||
|
||||
def _material_request_item(self, item, material_request_type, schedule_date):
|
||||
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
|
||||
project = (
|
||||
frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None
|
||||
)
|
||||
return {
|
||||
"item_code": item.item_code,
|
||||
"from_warehouse": from_warehouse,
|
||||
"qty": item.quantity - item.requested_qty,
|
||||
"uom": item.uom,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
"sales_order": item.sales_order,
|
||||
"production_plan": self.doc.name,
|
||||
"material_request_plan_item": item.name,
|
||||
"project": project,
|
||||
}
|
||||
|
||||
def _submit_material_requests(self, material_request_list):
|
||||
for material_request in material_request_list:
|
||||
material_request.flags.ignore_permissions = 1
|
||||
material_request.run_method("set_missing_values")
|
||||
material_request.save()
|
||||
if self.doc.get("submit_material_request"):
|
||||
material_request.submit()
|
||||
|
||||
frappe.flags.mute_messages = False
|
||||
if not material_request_list:
|
||||
msgprint(_("No material request created"))
|
||||
return
|
||||
|
||||
links = [get_link_to_form("Material Request", m.name) for m in material_request_list]
|
||||
msgprint(_("{0} created").format(comma_and(links)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items_for_material_requests(
|
||||
doc: str | frappe._dict | Document,
|
||||
warehouses: str | list | None = None,
|
||||
get_parent_warehouse_data: bool | int | None = None,
|
||||
):
|
||||
frappe.has_permission("Production Plan", "read", throw=True)
|
||||
|
||||
doc = _normalize_mr_doc(doc)
|
||||
warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data)
|
||||
doc["mr_items"] = []
|
||||
|
||||
po_items = _collect_po_items(doc)
|
||||
_validate_po_items(po_items)
|
||||
|
||||
ignore_ordered_qty = _effective_ignore_ordered_qty(doc, po_items)
|
||||
so_item_details = _collect_item_details(doc, po_items)
|
||||
|
||||
mr_items = _build_mr_items(doc, so_item_details, ignore_ordered_qty)
|
||||
mr_items = _apply_other_locations(
|
||||
doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data
|
||||
)
|
||||
|
||||
if not mr_items:
|
||||
_warn_no_mr_items(doc)
|
||||
return mr_items
|
||||
|
||||
|
||||
def _normalize_mr_doc(doc):
|
||||
if isinstance(doc, str):
|
||||
doc = frappe._dict(json.loads(doc))
|
||||
return doc
|
||||
|
||||
|
||||
def _filter_warehouses(doc, warehouses, get_parent_warehouse_data):
|
||||
if not warehouses:
|
||||
return warehouses
|
||||
|
||||
warehouses = list(set(get_warehouse_list(warehouses)))
|
||||
for_warehouse = doc.get("for_warehouse")
|
||||
if for_warehouse and not get_parent_warehouse_data and for_warehouse in warehouses:
|
||||
warehouses.remove(for_warehouse)
|
||||
return warehouses
|
||||
|
||||
|
||||
def _collect_po_items(doc):
|
||||
po_items = doc.get("po_items") if doc.get("po_items") else doc.get("items")
|
||||
for sa_row in doc.get("sub_assembly_items") or []:
|
||||
sa_row = frappe._dict(sa_row)
|
||||
if sa_row.type_of_manufacturing != "Material Request":
|
||||
continue
|
||||
po_items.append(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": sa_row.production_item,
|
||||
"required_qty": sa_row.qty,
|
||||
"include_exploded_items": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
return po_items
|
||||
|
||||
|
||||
def _validate_po_items(po_items):
|
||||
if not po_items or not [row.get("item_code") for row in po_items if row.get("item_code")]:
|
||||
frappe.throw(
|
||||
_("Items to Manufacture are required to pull the Raw Materials associated with it."),
|
||||
title=_("Items Required"),
|
||||
)
|
||||
|
||||
|
||||
def _effective_ignore_ordered_qty(doc, po_items):
|
||||
if doc.get("ignore_existing_ordered_qty"):
|
||||
return doc.get("ignore_existing_ordered_qty")
|
||||
return any(data.get("ignore_existing_ordered_qty") for data in po_items)
|
||||
|
||||
|
||||
def _build_sub_assembly_map(doc):
|
||||
if not (doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items")):
|
||||
return {}
|
||||
|
||||
sub_assembly_items = defaultdict(int)
|
||||
for d in doc.get("sub_assembly_items"):
|
||||
key = (d.get("production_item"), d.get("bom_no"), d.get("type_of_manufacturing"))
|
||||
sub_assembly_items[key] += d.get("qty")
|
||||
return {k[:2]: v for k, v in sub_assembly_items.items()}
|
||||
|
||||
|
||||
def _collect_item_details(doc, po_items):
|
||||
company = doc.get("company")
|
||||
sub_assembly_items = _build_sub_assembly_map(doc)
|
||||
existing_sub_assembly_items = set()
|
||||
so_item_details = frappe._dict()
|
||||
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
|
||||
for data in po_items:
|
||||
if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
|
||||
data["include_exploded_items"] = 1
|
||||
item_details = _item_details_for_row(
|
||||
doc, data, company, sub_assembly_items, existing_sub_assembly_items
|
||||
)
|
||||
_accumulate_so_items(so_item_details, data.get("sales_order"), item_details, qty_precision)
|
||||
return so_item_details
|
||||
|
||||
|
||||
def _item_details_for_row(doc, data, company, sub_assembly_items, existing_sub_assembly_items):
|
||||
planned_qty = data.get("required_qty") or data.get("planned_qty")
|
||||
if data.get("bom") or data.get("bom_no"):
|
||||
return _bom_item_details(
|
||||
doc, data, company, planned_qty, sub_assembly_items, existing_sub_assembly_items
|
||||
)
|
||||
if data.get("item_code"):
|
||||
return _plain_item_details(doc, data, planned_qty)
|
||||
return {}
|
||||
|
||||
|
||||
def _bom_item_details(doc, data, company, planned_qty, sub_assembly_items, existing_sub_assembly_items):
|
||||
bom_no, include_non_stock_items, include_subcontracted_items = _bom_explosion_flags(doc, data)
|
||||
if not planned_qty:
|
||||
frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx")))
|
||||
if not bom_no:
|
||||
return {}
|
||||
return _explode_bom_items(
|
||||
doc,
|
||||
data,
|
||||
company,
|
||||
bom_no,
|
||||
planned_qty,
|
||||
include_non_stock_items,
|
||||
include_subcontracted_items,
|
||||
sub_assembly_items,
|
||||
existing_sub_assembly_items,
|
||||
)
|
||||
|
||||
|
||||
def _bom_explosion_flags(doc, data):
|
||||
if data.get("required_qty"):
|
||||
include_subcontracted_items = 1 if data.get("include_exploded_items") else 0
|
||||
return data.get("bom"), 1, include_subcontracted_items
|
||||
return data.get("bom_no"), doc.get("include_non_stock_items"), doc.get("include_subcontracted_items")
|
||||
|
||||
|
||||
def _explode_bom_items(
|
||||
doc,
|
||||
data,
|
||||
company,
|
||||
bom_no,
|
||||
planned_qty,
|
||||
include_non_stock_items,
|
||||
include_subcontracted_items,
|
||||
sub_assembly_items,
|
||||
existing_sub_assembly_items,
|
||||
):
|
||||
item_details = {}
|
||||
if (
|
||||
data.get("include_exploded_items")
|
||||
and doc.get("skip_available_sub_assembly_item")
|
||||
and doc.get("sub_assembly_items")
|
||||
):
|
||||
return get_raw_materials_of_sub_assembly_items(
|
||||
existing_sub_assembly_items,
|
||||
item_details,
|
||||
company,
|
||||
bom_no,
|
||||
include_non_stock_items,
|
||||
sub_assembly_items,
|
||||
planned_qty=planned_qty,
|
||||
)
|
||||
if data.get("include_exploded_items") and include_subcontracted_items:
|
||||
return get_exploded_items(
|
||||
item_details, company, bom_no, include_non_stock_items, planned_qty=planned_qty, doc=doc
|
||||
)
|
||||
return get_subitems(
|
||||
doc,
|
||||
data,
|
||||
item_details,
|
||||
bom_no,
|
||||
company,
|
||||
include_non_stock_items,
|
||||
include_subcontracted_items,
|
||||
1,
|
||||
planned_qty=planned_qty,
|
||||
)
|
||||
|
||||
|
||||
def _plain_item_details(doc, data, planned_qty):
|
||||
item_master = frappe.get_doc("Item", data["item_code"]).as_dict()
|
||||
purchase_uom = item_master.purchase_uom or item_master.stock_uom
|
||||
conversion_factor = (
|
||||
get_uom_conversion_factor(item_master.name, purchase_uom) if item_master.purchase_uom else 1.0
|
||||
)
|
||||
return {
|
||||
item_master.item_code: frappe._dict(
|
||||
{
|
||||
"item_name": item_master.item_name,
|
||||
"default_bom": doc.bom,
|
||||
"purchase_uom": purchase_uom,
|
||||
"default_warehouse": item_master.default_warehouse,
|
||||
"min_order_qty": item_master.min_order_qty,
|
||||
"default_material_request_type": item_master.default_material_request_type,
|
||||
"qty": planned_qty or 1,
|
||||
"is_sub_contracted": item_master.is_sub_contracted_item,
|
||||
"item_code": item_master.name,
|
||||
"description": item_master.description,
|
||||
"stock_uom": item_master.stock_uom,
|
||||
"conversion_factor": conversion_factor,
|
||||
"safety_stock": item_master.safety_stock,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precision):
|
||||
for key, details in item_details.items():
|
||||
details.qty = flt(details.qty, qty_precision)
|
||||
so_item_details.setdefault(sales_order, frappe._dict())
|
||||
if key in so_item_details[sales_order]:
|
||||
existing = so_item_details[sales_order][key]
|
||||
existing["qty"] = existing.get("qty", 0) + flt(details.qty)
|
||||
else:
|
||||
so_item_details[sales_order][key] = details
|
||||
|
||||
|
||||
def _build_mr_items(doc, so_item_details, ignore_ordered_qty):
|
||||
mr_items = []
|
||||
consumed_qty = defaultdict(float)
|
||||
warehouse = doc.get("for_warehouse")
|
||||
company = doc.get("company")
|
||||
include_safety_stock = doc.get("include_safety_stock")
|
||||
|
||||
for sales_order, item_dict in so_item_details.items():
|
||||
for details in item_dict.values():
|
||||
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
|
||||
row = _mr_item_for_details(
|
||||
doc,
|
||||
details,
|
||||
sales_order,
|
||||
company,
|
||||
ignore_ordered_qty,
|
||||
include_safety_stock,
|
||||
warehouse,
|
||||
consumed_qty,
|
||||
)
|
||||
if row:
|
||||
mr_items.append(row)
|
||||
return mr_items
|
||||
|
||||
|
||||
def _mr_item_for_details(
|
||||
doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty
|
||||
):
|
||||
bin_dict = get_bin_details(details, doc.company, warehouse)
|
||||
bin_dict = bin_dict[0] if bin_dict else {}
|
||||
if details.qty <= 0:
|
||||
return None
|
||||
return get_material_request_items(
|
||||
doc,
|
||||
details,
|
||||
sales_order,
|
||||
company,
|
||||
ignore_ordered_qty,
|
||||
include_safety_stock,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
consumed_qty,
|
||||
)
|
||||
|
||||
|
||||
def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data):
|
||||
if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses):
|
||||
return mr_items
|
||||
|
||||
new_mr_items = []
|
||||
for item in mr_items:
|
||||
get_materials_from_other_locations(item, warehouses, new_mr_items, doc.get("company"))
|
||||
return new_mr_items
|
||||
|
||||
|
||||
def _warn_no_mr_items(doc):
|
||||
to_enable = frappe.bold(frappe.get_meta("Production Plan").get_field("ignore_existing_ordered_qty").label)
|
||||
warehouse = frappe.bold(doc.get("for_warehouse"))
|
||||
message = (
|
||||
_(
|
||||
"As there are sufficient raw materials, Material Request is not required for Warehouse {0}."
|
||||
).format(warehouse)
|
||||
+ "<br><br>"
|
||||
)
|
||||
message += _("If you still want to proceed, please enable {0}.").format(to_enable)
|
||||
frappe.msgprint(message, title=_("Note"))
|
||||
|
||||
|
||||
def get_material_request_items(
|
||||
doc,
|
||||
row,
|
||||
sales_order,
|
||||
company,
|
||||
ignore_existing_ordered_qty,
|
||||
include_safety_stock,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
consumed_qty,
|
||||
):
|
||||
required_qty = _required_qty_for_mr(
|
||||
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty
|
||||
)
|
||||
required_qty = _adjust_required_qty_for_uom(row, required_qty, include_safety_stock)
|
||||
item_group_defaults = get_item_group_defaults(row.item_code, company)
|
||||
conversion_factor = _mr_purchase_conversion_factor(row)
|
||||
return _material_request_item_row(
|
||||
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
)
|
||||
|
||||
|
||||
def _required_qty_for_mr(doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty):
|
||||
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||
required_qty = flt(row.get("qty"))
|
||||
else:
|
||||
key = (row.get("item_code"), warehouse)
|
||||
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
|
||||
if available_qty > 0:
|
||||
required_qty = max(0, flt(row.get("qty")) - available_qty)
|
||||
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
|
||||
else:
|
||||
required_qty = flt(row.get("qty"))
|
||||
|
||||
if doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
|
||||
required_qty = row["min_order_qty"]
|
||||
return required_qty
|
||||
|
||||
|
||||
def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
|
||||
if not row["purchase_uom"]:
|
||||
row["purchase_uom"] = row["stock_uom"]
|
||||
|
||||
if row["purchase_uom"] != row["stock_uom"]:
|
||||
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
|
||||
frappe.throw(
|
||||
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
|
||||
row["purchase_uom"], row["stock_uom"], row.item_code
|
||||
)
|
||||
)
|
||||
required_qty = required_qty / row["conversion_factor"]
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
if include_safety_stock:
|
||||
required_qty += flt(row["safety_stock"])
|
||||
return required_qty
|
||||
|
||||
|
||||
def _mr_purchase_conversion_factor(row):
|
||||
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
|
||||
if (
|
||||
row.get("default_material_request_type") == "Purchase"
|
||||
and item_details.purchase_uom
|
||||
and item_details.purchase_uom != item_details.stock_uom
|
||||
):
|
||||
return get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
|
||||
return 1.0
|
||||
|
||||
|
||||
def _material_request_item_row(
|
||||
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
):
|
||||
warehouse = (
|
||||
warehouse
|
||||
or row.get("source_warehouse")
|
||||
or row.get("default_warehouse")
|
||||
or item_group_defaults.get("default_warehouse")
|
||||
)
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
"quantity": required_qty / conversion_factor,
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
"warehouse": warehouse,
|
||||
"safety_stock": row.safety_stock,
|
||||
"actual_qty": bin_dict.get("actual_qty", 0),
|
||||
"projected_qty": bin_dict.get("projected_qty", 0),
|
||||
"ordered_qty": bin_dict.get("ordered_qty", 0),
|
||||
"reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0),
|
||||
"min_order_qty": row["min_order_qty"],
|
||||
"material_request_type": row.get("default_material_request_type"),
|
||||
"sales_order": sales_order,
|
||||
"description": row.get("description"),
|
||||
"uom": row.get("purchase_uom") or row.get("stock_uom"),
|
||||
"main_item_code": row.get("main_bom_item"),
|
||||
"from_bom": row.get("main_bom"),
|
||||
}
|
||||
|
||||
|
||||
def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
locations = get_available_item_locations(
|
||||
item.get("item_code"),
|
||||
warehouses,
|
||||
item.get("quantity") * item.get("conversion_factor"),
|
||||
company,
|
||||
ignore_validation=True,
|
||||
)
|
||||
|
||||
required_qty = item.get("quantity")
|
||||
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
|
||||
# Convert qty to stock UOM
|
||||
required_qty = required_qty * item.get("conversion_factor")
|
||||
|
||||
required_qty = _transfer_from_locations(item, locations, new_mr_items, required_qty)
|
||||
_add_remaining_purchase_request(item, new_mr_items, required_qty)
|
||||
|
||||
|
||||
def _transfer_from_locations(item, locations, new_mr_items, required_qty):
|
||||
# get available material by transferring to production warehouse
|
||||
for d in locations:
|
||||
if required_qty <= 0:
|
||||
return required_qty
|
||||
|
||||
new_dict = copy.deepcopy(item)
|
||||
quantity = required_qty if d.get("qty") > required_qty else d.get("qty")
|
||||
new_dict.update(
|
||||
{
|
||||
"quantity": quantity,
|
||||
"material_request_type": "Material Transfer",
|
||||
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
|
||||
"from_warehouse": d.get("warehouse"),
|
||||
"conversion_factor": 1.0,
|
||||
}
|
||||
)
|
||||
required_qty -= quantity
|
||||
new_mr_items.append(new_dict)
|
||||
return required_qty
|
||||
|
||||
|
||||
def _add_remaining_purchase_request(item, new_mr_items, required_qty):
|
||||
# raise purchase request for remaining qty
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
if flt(required_qty, precision) <= 0:
|
||||
return
|
||||
|
||||
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
item["quantity"] = required_qty / item.get("conversion_factor")
|
||||
new_mr_items.append(item)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_raw_materials(doc: str | dict | Document, warehouses: str | list | None = None):
|
||||
frappe.has_permission("Production Plan", "read", throw=True)
|
||||
|
||||
doc = _normalize_mr_doc(doc)
|
||||
item_list = [_raw_materials_header()]
|
||||
|
||||
doc.warehouse = None
|
||||
frappe.flags.show_qty_in_stock_uom = 1
|
||||
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
|
||||
|
||||
_build_download_rows(doc, items, item_list)
|
||||
build_csv_response(item_list, doc.name)
|
||||
|
||||
|
||||
def _raw_materials_header():
|
||||
return [
|
||||
"Item Code",
|
||||
"Item Name",
|
||||
"Description",
|
||||
"Stock UOM",
|
||||
"Warehouse",
|
||||
"Required Qty as per BOM",
|
||||
"Projected Qty",
|
||||
"Available Qty In Hand",
|
||||
"Ordered Qty",
|
||||
"Planned Qty",
|
||||
"Reserved Qty for Production",
|
||||
"Safety Stock",
|
||||
"Required Qty",
|
||||
]
|
||||
|
||||
|
||||
def _build_download_rows(doc, items, item_list):
|
||||
duplicate_item_wh_list = frappe._dict()
|
||||
for d in items:
|
||||
key = (d.get("item_code"), d.get("warehouse"))
|
||||
if key in duplicate_item_wh_list:
|
||||
duplicate_item_wh_list[key][12] += d.get("quantity")
|
||||
continue
|
||||
|
||||
rm_data = _raw_material_row(d)
|
||||
duplicate_item_wh_list[key] = rm_data
|
||||
item_list.append(rm_data)
|
||||
|
||||
if not doc.get("for_warehouse"):
|
||||
_append_other_warehouse_bins(item_list, d, doc)
|
||||
|
||||
|
||||
def _raw_material_row(d):
|
||||
return [
|
||||
d.get("item_code"),
|
||||
d.get("item_name"),
|
||||
d.get("description"),
|
||||
d.get("stock_uom"),
|
||||
d.get("warehouse"),
|
||||
d.get("required_bom_qty"),
|
||||
d.get("projected_qty"),
|
||||
d.get("actual_qty"),
|
||||
d.get("ordered_qty"),
|
||||
d.get("planned_qty"),
|
||||
d.get("reserved_qty_for_production"),
|
||||
d.get("safety_stock"),
|
||||
d.get("quantity"),
|
||||
]
|
||||
|
||||
|
||||
def _append_other_warehouse_bins(item_list, d, doc):
|
||||
row = {"item_code": d.get("item_code")}
|
||||
for bin_dict in get_bin_details(row, doc.company, all_warehouse=True):
|
||||
if d.get("warehouse") == bin_dict.get("warehouse"):
|
||||
continue
|
||||
|
||||
item_list.append(
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
bin_dict.get("warehouse"),
|
||||
"",
|
||||
bin_dict.get("projected_qty", 0),
|
||||
bin_dict.get("actual_qty", 0),
|
||||
bin_dict.get("ordered_qty", 0),
|
||||
bin_dict.get("reserved_qty_for_production", 0),
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,181 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""Sub-assembly item resolution for a Production Plan (extracted from production_plan.py)."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_planning import (
|
||||
get_sub_assembly_items,
|
||||
)
|
||||
|
||||
|
||||
class SubAssemblyService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def get_sub_assembly_items(self, manufacturing_type: str | None = None):
|
||||
"Fetch sub assembly items and optionally combine them."
|
||||
self.doc.sub_assembly_items = []
|
||||
sub_assembly_items_store = [] # temporary store to process all subassembly items
|
||||
bin_details = frappe._dict()
|
||||
|
||||
processed_any = False
|
||||
for row in self.doc.po_items:
|
||||
if self._collect_row_sub_assembly_items(
|
||||
row, sub_assembly_items_store, bin_details, manufacturing_type
|
||||
):
|
||||
processed_any = True
|
||||
|
||||
if processed_any and not sub_assembly_items_store and self.doc.skip_available_sub_assembly_item:
|
||||
self._warn_sufficient_sub_assembly()
|
||||
|
||||
if self.doc.combine_sub_items:
|
||||
sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store)
|
||||
|
||||
for idx, row in enumerate(sub_assembly_items_store):
|
||||
row.idx = idx + 1
|
||||
self.doc.append("sub_assembly_items", row)
|
||||
|
||||
self.set_default_supplier_for_subcontracting_order()
|
||||
|
||||
def _collect_row_sub_assembly_items(self, row, sub_assembly_items_store, bin_details, manufacturing_type):
|
||||
self._validate_sub_assembly_row(row)
|
||||
if self._bom_tracks_semi_finished(row):
|
||||
return False
|
||||
|
||||
bom_data = []
|
||||
get_sub_assembly_items(
|
||||
[item.production_item for item in sub_assembly_items_store],
|
||||
bin_details,
|
||||
row.bom_no,
|
||||
bom_data,
|
||||
row.planned_qty,
|
||||
self.doc.company,
|
||||
warehouse=self.doc.sub_assembly_warehouse,
|
||||
skip_available_sub_assembly_item=self.doc.skip_available_sub_assembly_item,
|
||||
)
|
||||
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
|
||||
sub_assembly_items_store.extend(bom_data)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _bom_tracks_semi_finished(row):
|
||||
if not frappe.db.get_value("BOM", row.bom_no, "track_semi_finished_goods"):
|
||||
return False
|
||||
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"Row #{0}: Since 'Track Semi Finished Goods' is enabled, the BOM {1} cannot be used for Sub Assembly Items"
|
||||
).format(row.idx, row.bom_no)
|
||||
)
|
||||
return True
|
||||
|
||||
def _validate_sub_assembly_row(self, row):
|
||||
if self.doc.skip_available_sub_assembly_item and not self.doc.sub_assembly_warehouse:
|
||||
frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx))
|
||||
if not row.item_code:
|
||||
frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
|
||||
if not row.bom_no:
|
||||
frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx))
|
||||
|
||||
def _warn_sufficient_sub_assembly(self):
|
||||
label = self.meta.get_field("skip_available_sub_assembly_item").label
|
||||
message = (
|
||||
_(
|
||||
"As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
|
||||
).format(self.doc.sub_assembly_warehouse)
|
||||
+ "<br><br>"
|
||||
)
|
||||
message += _("If you still want to proceed, please disable {0} checkbox.").format(label)
|
||||
frappe.msgprint(message, title=_("Note"))
|
||||
|
||||
def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
|
||||
"Modify bom_data, set additional details."
|
||||
is_group_warehouse = frappe.db.get_value("Warehouse", self.doc.sub_assembly_warehouse, "is_group")
|
||||
|
||||
for data in bom_data:
|
||||
data.qty = data.stock_qty
|
||||
data.production_plan_item = row.name
|
||||
data.schedule_date = row.planned_start_date
|
||||
data.type_of_manufacturing = manufacturing_type or (
|
||||
"Subcontract" if data.is_sub_contracted_item else "In House"
|
||||
)
|
||||
|
||||
if not is_group_warehouse:
|
||||
data.fg_warehouse = self.doc.sub_assembly_warehouse
|
||||
|
||||
if not self.doc.combine_sub_items:
|
||||
data.sales_order = row.sales_order
|
||||
data.sales_order_item = row.sales_order_item
|
||||
|
||||
def set_default_supplier_for_subcontracting_order(self):
|
||||
items = [
|
||||
d.production_item for d in self.doc.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
|
||||
]
|
||||
if not items:
|
||||
return
|
||||
|
||||
default_supplier = self._default_suppliers(items)
|
||||
if not default_supplier:
|
||||
return
|
||||
|
||||
for row in self.doc.sub_assembly_items:
|
||||
if row.type_of_manufacturing == "Subcontract":
|
||||
row.supplier = default_supplier.get(row.production_item)
|
||||
|
||||
@staticmethod
|
||||
def _default_suppliers(items):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Item Default",
|
||||
fields=["parent", "default_supplier"],
|
||||
filters={"parent": ("in", items), "default_supplier": ("is", "set")},
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
def combine_subassembly_items(self, sub_assembly_items_store):
|
||||
"Aggregate if same: Item, Warehouse, Inhouse/Outhouse Manu.g, BOM No."
|
||||
key_wise_data = {}
|
||||
for row in sub_assembly_items_store:
|
||||
key = (
|
||||
row.get("production_item"),
|
||||
row.get("fg_warehouse"),
|
||||
row.get("bom_no"),
|
||||
row.get("type_of_manufacturing"),
|
||||
)
|
||||
existing_row = key_wise_data.get(key)
|
||||
if existing_row:
|
||||
self._merge_subassembly_row(existing_row, row)
|
||||
else:
|
||||
key_wise_data[key] = row
|
||||
|
||||
return list(key_wise_data.values())
|
||||
|
||||
@staticmethod
|
||||
def _merge_subassembly_row(existing_row, row):
|
||||
existing_row.qty += flt(row.qty)
|
||||
existing_row.stock_qty += flt(row.stock_qty)
|
||||
existing_row.bom_level = max(existing_row.bom_level, row.bom_level)
|
||||
|
||||
def all_items_completed(self):
|
||||
all_items_produced = all(
|
||||
flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 for d in self.doc.po_items
|
||||
)
|
||||
if not all_items_produced:
|
||||
return False
|
||||
|
||||
wo_status = frappe.get_all(
|
||||
"Work Order",
|
||||
filters={
|
||||
"production_plan": self.doc.name,
|
||||
"status": ("not in", ["Closed", "Stopped"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields="status",
|
||||
pluck="status",
|
||||
)
|
||||
return all(s == "Completed" for s in wo_status)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
296
erpnext/manufacturing/doctype/work_order/services/operations.py
Normal file
296
erpnext/manufacturing/doctype/work_order/services/operations.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Operation scheduling, costing and job-card preparation for Work Order.
|
||||
|
||||
Extracted from work_order.py. ``OperationsService`` wraps a Work Order document
|
||||
(composition); work_order.py keeps thin delegating stubs for the methods that
|
||||
are called from other modules.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from frappe import _
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_datetime,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
time_diff_in_hours,
|
||||
)
|
||||
|
||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||
get_mins_between_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import (
|
||||
create_job_card,
|
||||
split_qty_based_on_batch_size,
|
||||
)
|
||||
|
||||
_BOM_OPERATION_FIELDS = [
|
||||
"operation",
|
||||
"description",
|
||||
"workstation",
|
||||
"idx",
|
||||
"finished_good",
|
||||
"is_subcontracted",
|
||||
"wip_warehouse",
|
||||
"source_warehouse",
|
||||
"fg_warehouse",
|
||||
"workstation_type",
|
||||
"base_hour_rate as hour_rate",
|
||||
"time_in_mins",
|
||||
"parent as bom",
|
||||
"bom_no",
|
||||
"batch_size",
|
||||
"sequence_id",
|
||||
"fixed_time",
|
||||
"skip_material_transfer",
|
||||
"backflush_from_wip_warehouse",
|
||||
"set_cost_based_on_bom_qty",
|
||||
"quality_inspection_required",
|
||||
]
|
||||
|
||||
|
||||
class OperationsService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def calculate_operating_cost(self):
|
||||
self.doc.planned_operating_cost, self.doc.actual_operating_cost = 0.0, 0.0
|
||||
for d in self.doc.get("operations"):
|
||||
self._set_operation_cost(d)
|
||||
self.doc.planned_operating_cost += flt(d.planned_operating_cost)
|
||||
self.doc.actual_operating_cost += flt(d.actual_operating_cost)
|
||||
|
||||
variable_cost = self.doc.actual_operating_cost or self.doc.planned_operating_cost
|
||||
self.doc.total_operating_cost = (
|
||||
flt(self.doc.additional_operating_cost)
|
||||
+ flt(variable_cost)
|
||||
+ flt(self.doc.corrective_operation_cost)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _set_operation_cost(d):
|
||||
if not d.hour_rate and d.workstation:
|
||||
d.hour_rate = get_hour_rate(d.workstation)
|
||||
|
||||
d.planned_operating_cost = flt(
|
||||
flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost")
|
||||
)
|
||||
d.actual_operating_cost = flt(
|
||||
flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost")
|
||||
)
|
||||
|
||||
def create_job_card(self):
|
||||
manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings")
|
||||
|
||||
enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning)
|
||||
plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30
|
||||
|
||||
for idx, row in enumerate(self.doc.operations):
|
||||
qty = self.doc.qty
|
||||
while qty > 0:
|
||||
qty = split_qty_based_on_batch_size(self.doc, row, qty)
|
||||
if row.job_card_qty > 0:
|
||||
self.prepare_data_for_job_card(row, idx, plan_days, enable_capacity_planning)
|
||||
|
||||
planned_end_date = self.doc.operations and self.doc.operations[-1].planned_end_time
|
||||
if planned_end_date:
|
||||
self.doc.db_set("planned_end_date", planned_end_date)
|
||||
|
||||
def prepare_data_for_job_card(self, row, idx, plan_days, enable_capacity_planning):
|
||||
self.set_operation_start_end_time(row, idx)
|
||||
|
||||
job_card_doc = create_job_card(
|
||||
self.doc, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
|
||||
)
|
||||
|
||||
if enable_capacity_planning and job_card_doc:
|
||||
row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time
|
||||
row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time
|
||||
self._validate_capacity_window(row, plan_days)
|
||||
row.db_update()
|
||||
|
||||
def _validate_capacity_window(self, row, plan_days):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import CapacityError
|
||||
|
||||
if date_diff(row.planned_end_time, self.doc.planned_start_date) <= plan_days:
|
||||
return
|
||||
|
||||
frappe.message_log.pop()
|
||||
msg = _(
|
||||
"Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}."
|
||||
).format(
|
||||
plan_days, row.operation, get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
|
||||
)
|
||||
frappe.throw(msg, CapacityError)
|
||||
|
||||
def set_operation_start_end_time(self, row, idx):
|
||||
"""Set start and end time for given operation. If first operation, set start as
|
||||
`planned_start_date`, else add time diff to end time of earlier operation."""
|
||||
if idx == 0:
|
||||
# first operation at planned_start date
|
||||
row.planned_start_time = self.doc.planned_start_date
|
||||
elif self.doc.operations[idx - 1].sequence_id:
|
||||
row.planned_start_time = self._sequence_based_start_time(row, idx)
|
||||
else:
|
||||
previous_end = get_datetime(self.doc.operations[idx - 1].planned_end_time)
|
||||
row.planned_start_time = previous_end + get_mins_between_operations()
|
||||
|
||||
row.planned_end_time = get_datetime(row.planned_start_time) + relativedelta(minutes=row.time_in_mins)
|
||||
|
||||
if row.planned_start_time == row.planned_end_time:
|
||||
frappe.throw(_("Capacity Planning Error, planned start time can not be same as end time"))
|
||||
|
||||
def _sequence_based_start_time(self, row, idx):
|
||||
previous = self.doc.operations[idx - 1]
|
||||
if previous.sequence_id == row.sequence_id:
|
||||
return previous.planned_start_time
|
||||
|
||||
same_sequence = sorted(
|
||||
[op for op in self.doc.operations if op.sequence_id == previous.sequence_id],
|
||||
key=lambda op: get_datetime(op.planned_end_time),
|
||||
)
|
||||
return get_datetime(same_sequence[-1].planned_end_time) + get_mins_between_operations()
|
||||
|
||||
def set_work_order_operations(self):
|
||||
"""Fetch operations from BOM and set in 'Work Order'"""
|
||||
self.doc.set("operations", [])
|
||||
if not self.doc.bom_no or not frappe.get_cached_value("BOM", self.doc.bom_no, "with_operations"):
|
||||
return
|
||||
|
||||
operations = self._collect_bom_operations()
|
||||
for correct_index, operation in enumerate(operations, start=1):
|
||||
operation.idx = correct_index
|
||||
|
||||
self.doc.set("operations", operations)
|
||||
self.calculate_time()
|
||||
|
||||
def _collect_bom_operations(self):
|
||||
operations = []
|
||||
if self.doc.use_multi_level_bom:
|
||||
bom_tree = frappe.get_doc("BOM", self.doc.bom_no).get_tree_representation()
|
||||
for node in reversed(bom_tree.level_order_traversal()):
|
||||
if node.is_bom:
|
||||
qty = node.exploded_qty / node.bom_qty
|
||||
operations.extend(self._bom_operations(node.name, qty=qty, exploded=True))
|
||||
|
||||
bom_qty = frappe.get_cached_value("BOM", self.doc.bom_no, "quantity")
|
||||
operations.extend(self._bom_operations(self.doc.bom_no, qty=bom_qty))
|
||||
return operations
|
||||
|
||||
def _bom_operations(self, bom_no, qty=1, exploded=False):
|
||||
data = frappe.get_all(
|
||||
"BOM Operation", filters={"parent": bom_no}, fields=_BOM_OPERATION_FIELDS, order_by="idx"
|
||||
)
|
||||
for d in data:
|
||||
self._adjust_operation_row(d, qty, exploded)
|
||||
return data
|
||||
|
||||
def _adjust_operation_row(self, d, qty, exploded):
|
||||
if not d.fixed_time:
|
||||
if frappe.get_value("Operation", d.operation, "create_job_card_based_on_batch_size"):
|
||||
qty = d.batch_size
|
||||
d.time_in_mins = d.time_in_mins * flt(qty) if exploded else d.time_in_mins / flt(qty)
|
||||
|
||||
d.status = "Pending"
|
||||
if self.doc.track_semi_finished_goods and not d.sequence_id:
|
||||
d.sequence_id = d.idx
|
||||
|
||||
def calculate_time(self):
|
||||
for d in self.doc.get("operations"):
|
||||
if not d.fixed_time:
|
||||
d.time_in_mins = flt(d.time_in_mins) * flt(self.doc.qty)
|
||||
|
||||
self.calculate_operating_cost()
|
||||
|
||||
def get_holidays(self, workstation):
|
||||
holiday_list = frappe.db.get_value("Workstation", workstation, "holiday_list")
|
||||
|
||||
holidays = {}
|
||||
|
||||
if holiday_list not in holidays:
|
||||
holiday_list_days = [
|
||||
getdate(d[0])
|
||||
for d in frappe.get_all(
|
||||
"Holiday",
|
||||
fields=["holiday_date"],
|
||||
filters={"parent": holiday_list},
|
||||
order_by="holiday_date",
|
||||
limit_page_length=0,
|
||||
as_list=1,
|
||||
)
|
||||
]
|
||||
|
||||
holidays[holiday_list] = holiday_list_days
|
||||
|
||||
return holidays[holiday_list]
|
||||
|
||||
def update_operation_status(self):
|
||||
allowance_percentage = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||
)
|
||||
max_allowed_qty_for_wo = flt(self.doc.qty) + (allowance_percentage / 100 * flt(self.doc.qty))
|
||||
|
||||
for d in self.doc.get("operations"):
|
||||
d.status = self._operation_status(d, max_allowed_qty_for_wo)
|
||||
|
||||
def _operation_status(self, d, max_allowed_qty_for_wo):
|
||||
precision = d.precision("completed_qty")
|
||||
qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
|
||||
if not qty:
|
||||
return "Pending"
|
||||
if qty < flt(self.doc.qty, precision):
|
||||
return "Work in Progress"
|
||||
if qty <= flt(max_allowed_qty_for_wo, precision):
|
||||
return "Completed"
|
||||
frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
|
||||
|
||||
def set_actual_dates(self):
|
||||
if self.doc.get("operations"):
|
||||
self._set_dates_from_operations()
|
||||
else:
|
||||
self._set_dates_from_stock_entries()
|
||||
|
||||
self.set_lead_time()
|
||||
|
||||
def _set_dates_from_operations(self):
|
||||
operations = self.doc.get("operations")
|
||||
start_dates = [d.actual_start_time for d in operations if d.actual_start_time]
|
||||
if start_dates:
|
||||
self.doc.actual_start_date = min(start_dates)
|
||||
|
||||
end_dates = [d.actual_end_time for d in operations if d.actual_end_time]
|
||||
if end_dates:
|
||||
self.doc.actual_end_date = max(end_dates)
|
||||
|
||||
def _set_dates_from_stock_entries(self):
|
||||
data = frappe.get_all(
|
||||
"Stock Entry",
|
||||
fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}],
|
||||
filters={
|
||||
"work_order": self.doc.name,
|
||||
"purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]),
|
||||
},
|
||||
)
|
||||
if not data:
|
||||
return
|
||||
|
||||
dates = [d.posting_datetime for d in data]
|
||||
self.doc.db_set("actual_start_date", min(dates))
|
||||
if self.doc.status == "Completed":
|
||||
self.doc.db_set("actual_end_date", max(dates))
|
||||
|
||||
def set_lead_time(self):
|
||||
if self.doc.actual_start_date and self.doc.actual_end_date:
|
||||
self.doc.lead_time = flt(
|
||||
time_diff_in_hours(self.doc.actual_end_date, self.doc.actual_start_date) * 60
|
||||
)
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_hour_rate(workstation):
|
||||
return frappe.get_cached_value("Workstation", workstation, "hour_rate") or 0.0
|
||||
@@ -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,
|
||||
}
|
||||
424
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
424
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
@@ -0,0 +1,424 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Status and quantity-rollup logic for Work Order.
|
||||
|
||||
Extracted from work_order.py. ``StatusService`` wraps a Work Order document
|
||||
(composition); work_order.py keeps thin delegating stubs so the many external
|
||||
callers (job cards, sales orders, production plans, patches) keep working.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
|
||||
|
||||
_QTY_PURPOSES = (
|
||||
("Manufacture", "produced_qty"),
|
||||
("Material Transfer for Manufacture", "material_transferred_for_manufacturing"),
|
||||
("Material Transfer for Manufacture", "additional_transferred_qty"),
|
||||
)
|
||||
|
||||
|
||||
class StatusService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_work_order_against_so(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
|
||||
|
||||
total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty)
|
||||
so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty())
|
||||
allowance_percentage = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order")
|
||||
)
|
||||
if total_qty <= so_qty + (allowance_percentage / 100 * so_qty):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format(
|
||||
get_link_to_form("Item", self.doc.production_item),
|
||||
frappe.bold(so_qty),
|
||||
frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")),
|
||||
),
|
||||
OverProductionError,
|
||||
)
|
||||
|
||||
def _ordered_qty_against_so(self):
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
return (
|
||||
frappe.qb.from_(wo)
|
||||
.select(Sum(wo.qty - wo.process_loss_qty))
|
||||
.where(
|
||||
(wo.production_item == self.doc.production_item)
|
||||
& (wo.sales_order == self.doc.sales_order)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo.status != "Closed")
|
||||
& (wo.name != self.doc.name)
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
def _so_item_qty(self):
|
||||
so_item = frappe.qb.DocType("Sales Order Item")
|
||||
return (
|
||||
frappe.qb.from_(so_item)
|
||||
.select(Sum(so_item.stock_qty))
|
||||
.where(
|
||||
(so_item.parent == self.doc.sales_order)
|
||||
& (so_item.item_code == self.doc.production_item)
|
||||
& (so_item.docstatus == 1)
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
def _packed_item_qty(self):
|
||||
packed_item = frappe.qb.DocType("Packed Item")
|
||||
return (
|
||||
frappe.qb.from_(packed_item)
|
||||
.select(Sum(packed_item.qty))
|
||||
.where(
|
||||
(packed_item.parent == self.doc.sales_order)
|
||||
& (packed_item.parenttype == "Sales Order")
|
||||
& (packed_item.item_code == self.doc.production_item)
|
||||
& (packed_item.docstatus == 1)
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
def update_status(self, status=None):
|
||||
"""Update status of work order if unknown"""
|
||||
if self.doc.status != "Closed":
|
||||
if status not in ["Stopped", "Closed"]:
|
||||
status = self.get_status(status)
|
||||
|
||||
if status != self.doc.status:
|
||||
self.doc.db_set("status", status)
|
||||
|
||||
self.doc.update_required_items()
|
||||
|
||||
return status or self.doc.status
|
||||
|
||||
def get_status(self, status=None):
|
||||
"""Return the status based on stock entries against this work order"""
|
||||
status = status or self.doc.status
|
||||
|
||||
if self.doc.docstatus == 0:
|
||||
status = "Draft"
|
||||
elif self.doc.docstatus == 1:
|
||||
status = self._submitted_status(status)
|
||||
else:
|
||||
status = "Cancelled"
|
||||
|
||||
if self._is_partial_skip_transfer():
|
||||
status = "In Process"
|
||||
|
||||
if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations):
|
||||
status = "In Process"
|
||||
|
||||
if status == "Not Started" and self.doc.reserve_stock:
|
||||
status = self._reservation_status(status)
|
||||
|
||||
return status
|
||||
|
||||
def _submitted_status(self, status):
|
||||
if status in ["Closed", "Stopped"]:
|
||||
return status
|
||||
|
||||
status = "In Process" if flt(self.doc.material_transferred_for_manufacturing) > 0 else "Not Started"
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision)
|
||||
if flt(total_qty, precision) >= flt(self.doc.qty, precision):
|
||||
status = "Completed"
|
||||
return status
|
||||
|
||||
def _is_partial_skip_transfer(self):
|
||||
return bool(
|
||||
self.doc.skip_transfer
|
||||
and self.doc.produced_qty
|
||||
and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty))
|
||||
)
|
||||
|
||||
def _reservation_status(self, status):
|
||||
for row in self.doc.required_items:
|
||||
if not row.stock_reserved_qty:
|
||||
continue
|
||||
|
||||
if row.stock_reserved_qty >= row.required_qty:
|
||||
status = "Stock Reserved"
|
||||
else:
|
||||
return "Stock Partially Reserved"
|
||||
return status
|
||||
|
||||
def update_work_order_qty(self):
|
||||
"""Update Manufactured Qty and Material Transferred for Qty based on Stock Entry"""
|
||||
if self.doc.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
for purpose, fieldname in _QTY_PURPOSES:
|
||||
self._update_qty_for_purpose(purpose, fieldname)
|
||||
|
||||
if self.doc.production_plan:
|
||||
self.set_produced_qty_for_sub_assembly_item()
|
||||
self.update_production_plan_status()
|
||||
|
||||
if self.doc.additional_transferred_qty:
|
||||
self.doc.validate_additional_transferred_qty()
|
||||
|
||||
def _update_qty_for_purpose(self, purpose, fieldname):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError
|
||||
|
||||
if self._skip_transfer_purpose(purpose):
|
||||
return
|
||||
|
||||
qty = self.get_transferred_or_manufactured_qty(purpose, fieldname)
|
||||
completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty)
|
||||
if qty > completed_qty:
|
||||
frappe.throw(
|
||||
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
|
||||
_(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name
|
||||
),
|
||||
StockOverProductionError,
|
||||
)
|
||||
|
||||
self.doc.db_set(fieldname, qty)
|
||||
self.set_process_loss_qty()
|
||||
self._update_produced_qty_in_so()
|
||||
|
||||
def _skip_transfer_purpose(self, purpose):
|
||||
return bool(
|
||||
purpose == "Material Transfer for Manufacture"
|
||||
and self.doc.operations
|
||||
and self.doc.transfer_material_against == "Job Card"
|
||||
)
|
||||
|
||||
def _qty_allowance(self, purpose):
|
||||
allowance = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||
)
|
||||
if not allowance and purpose == "Material Transfer for Manufacture":
|
||||
allowance = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
|
||||
)
|
||||
return allowance
|
||||
|
||||
def _update_produced_qty_in_so(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
||||
|
||||
if (
|
||||
self.doc.sales_order
|
||||
and self.doc.sales_order_item
|
||||
and not self.doc.production_plan_sub_assembly_item
|
||||
):
|
||||
update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item)
|
||||
|
||||
def update_disassembled_qty(self, qty, is_cancel=False):
|
||||
if is_cancel:
|
||||
self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty)
|
||||
else:
|
||||
if self.doc.docstatus == 1:
|
||||
self.doc.disassembled_qty += qty
|
||||
|
||||
if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty:
|
||||
frappe.throw(_("Cannot disassemble more than produced quantity."))
|
||||
|
||||
self.doc.db_set("disassembled_qty", self.doc.disassembled_qty)
|
||||
|
||||
def get_transferred_or_manufactured_qty(self, purpose, fieldname):
|
||||
parent = frappe.qb.DocType("Stock Entry")
|
||||
is_additional = cint(fieldname == "additional_transferred_qty")
|
||||
query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional))
|
||||
|
||||
if purpose == "Manufacture":
|
||||
child = frappe.qb.DocType("Stock Entry Detail")
|
||||
query = (
|
||||
query.join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(Sum(child.transfer_qty))
|
||||
.where(child.is_finished_item == 1)
|
||||
)
|
||||
else:
|
||||
query = query.select(Sum(parent.fg_completed_qty))
|
||||
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
def _stock_entry_filter(self, parent, purpose, is_additional):
|
||||
return (
|
||||
(parent.work_order == self.doc.name)
|
||||
& (parent.docstatus == 1)
|
||||
& (parent.purpose == purpose)
|
||||
& (parent.is_additional_transfer_entry == is_additional)
|
||||
)
|
||||
|
||||
def set_process_loss_qty(self):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
process_loss_qty = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where(
|
||||
(table.work_order == self.doc.name)
|
||||
& (table.purpose == "Manufacture")
|
||||
& (table.docstatus == 1)
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
self.doc.db_set("process_loss_qty", flt(process_loss_qty))
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
produced_qty = 0
|
||||
if self.doc.production_plan_item:
|
||||
total_qty = frappe.get_all(
|
||||
"Work Order",
|
||||
fields=[{"SUM": "produced_qty", "as": "produced_qty"}],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"production_plan": self.doc.production_plan,
|
||||
"production_plan_item": self.doc.production_plan_item,
|
||||
},
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
produced_qty = total_qty[0][0] if total_qty else 0
|
||||
|
||||
self.update_status()
|
||||
production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item)
|
||||
|
||||
def update_planned_qty(self):
|
||||
if self.doc.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict())
|
||||
|
||||
if self.doc.material_request:
|
||||
mr_obj = frappe.get_doc("Material Request", self.doc.material_request)
|
||||
mr_obj.update_requested_qty([self.doc.material_request_item])
|
||||
|
||||
def _planned_qty_dict(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_reserved_qty_for_sub_assembly,
|
||||
)
|
||||
|
||||
qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)}
|
||||
if self.doc.production_plan_sub_assembly_item and self.doc.production_plan:
|
||||
qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly(
|
||||
self.doc.production_item, self.doc.fg_warehouse
|
||||
)
|
||||
return qty_dict
|
||||
|
||||
def set_produced_qty_for_sub_assembly_item(self):
|
||||
produced_qty = self._sub_assembly_produced_qty()
|
||||
frappe.db.set_value(
|
||||
"Production Plan Sub Assembly Item",
|
||||
self.doc.production_plan_sub_assembly_item,
|
||||
"wo_produced_qty",
|
||||
produced_qty,
|
||||
)
|
||||
|
||||
def _sub_assembly_produced_qty(self):
|
||||
table = frappe.qb.DocType("Work Order")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.produced_qty))
|
||||
.where(
|
||||
(table.production_plan == self.doc.production_plan)
|
||||
& (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item)
|
||||
& (table.docstatus == 1)
|
||||
)
|
||||
).run()
|
||||
return flt(query[0][0]) if query else 0
|
||||
|
||||
def update_ordered_qty(self):
|
||||
if not (
|
||||
self.doc.production_plan
|
||||
and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item)
|
||||
):
|
||||
return
|
||||
|
||||
qty = self._production_plan_ordered_qty()
|
||||
if self.doc.production_plan_item:
|
||||
frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty)
|
||||
elif self.doc.production_plan_sub_assembly_item:
|
||||
field = self.doc.production_plan_sub_assembly_item
|
||||
frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty)
|
||||
|
||||
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
def _production_plan_ordered_qty(self):
|
||||
table = frappe.qb.DocType("Work Order")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty))
|
||||
.where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1))
|
||||
)
|
||||
if self.doc.production_plan_item:
|
||||
query = query.where(table.production_plan_item == self.doc.production_plan_item)
|
||||
elif self.doc.production_plan_sub_assembly_item:
|
||||
query = query.where(
|
||||
table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item
|
||||
)
|
||||
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
def update_work_order_qty_in_so(self):
|
||||
if (
|
||||
not self.doc.sales_order and not self.doc.sales_order_item
|
||||
) or self.doc.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
total_bundle_qty = self._total_bundle_qty()
|
||||
work_order_qty = self._sales_order_work_order_qty()
|
||||
frappe.db.set_value(
|
||||
"Sales Order Item",
|
||||
self.doc.sales_order_item,
|
||||
"work_order_qty",
|
||||
flt(work_order_qty / total_bundle_qty, 2),
|
||||
)
|
||||
|
||||
def _sales_order_work_order_qty(self):
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
query = (
|
||||
frappe.qb.from_(wo)
|
||||
.select(Sum(wo.qty))
|
||||
.where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed"))
|
||||
)
|
||||
if self.doc.product_bundle_item:
|
||||
query = query.where(wo.product_bundle_item == self.doc.product_bundle_item)
|
||||
else:
|
||||
query = query.where(wo.production_item == self.doc.production_item)
|
||||
|
||||
qty = query.run(as_list=1)
|
||||
return qty[0][0] if qty and qty[0][0] else 0
|
||||
|
||||
def update_work_order_qty_in_combined_so(self):
|
||||
total_bundle_qty = self._total_bundle_qty()
|
||||
prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
item_reference = frappe.get_value(
|
||||
"Production Plan Item", self.doc.production_plan_item, "sales_order_item"
|
||||
)
|
||||
|
||||
for plan_reference in prod_plan.prod_plan_references:
|
||||
if plan_reference.item_reference != item_reference:
|
||||
continue
|
||||
|
||||
qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0
|
||||
frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty)
|
||||
|
||||
def _total_bundle_qty(self):
|
||||
if not self.doc.product_bundle_item:
|
||||
return 1
|
||||
|
||||
pbi = frappe.qb.DocType("Product Bundle Item")
|
||||
total_bundle_qty = (
|
||||
frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item)
|
||||
).run()[0][0]
|
||||
# product bundle is 0 (product bundle allows 0 qty for items)
|
||||
return total_bundle_qty or 1
|
||||
|
||||
def update_completed_qty_in_material_request(self):
|
||||
if self.doc.material_request and self.doc.material_request_item:
|
||||
frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty(
|
||||
[self.doc.material_request_item]
|
||||
)
|
||||
@@ -0,0 +1,654 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Stock reservation logic for Work Order.
|
||||
|
||||
Extracted from work_order.py. ``StockReservationService`` wraps a Work Order
|
||||
document (composition) and owns the reservation-related behaviour; the
|
||||
module-level helpers are reused by the controller and by Production Plan.
|
||||
work_order.py re-exports them to preserve whitelist dotted-paths and imports.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import cint, flt, parse_json
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
|
||||
|
||||
_SCIO_FIELDS = [
|
||||
"item_code",
|
||||
"name",
|
||||
"qty as stock_qty",
|
||||
"produced_qty as stock_reserved_qty",
|
||||
"delivery_warehouse as warehouse",
|
||||
"parent as voucher_no",
|
||||
"parenttype as voucher_type",
|
||||
"delivered_qty",
|
||||
]
|
||||
_SO_FIELDS = [
|
||||
"item_code",
|
||||
"name",
|
||||
"stock_qty",
|
||||
"stock_reserved_qty",
|
||||
"warehouse",
|
||||
"parent as voucher_no",
|
||||
"parenttype as voucher_type",
|
||||
"delivered_qty",
|
||||
]
|
||||
_SERIAL_BATCH_FIELDS = [
|
||||
"`tabSerial and Batch Entry`.`serial_no`",
|
||||
"`tabSerial and Batch Entry`.`batch_no`",
|
||||
"`tabSerial and Batch Entry`.`qty`",
|
||||
"`tabSerial and Batch Bundle`.`warehouse`",
|
||||
"`tabSerial and Batch Bundle`.`item_code`",
|
||||
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
|
||||
]
|
||||
|
||||
|
||||
class StockReservationService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_fg_warehouse_for_reservation(self):
|
||||
if not (
|
||||
self.doc.reserve_stock
|
||||
and self.doc.sales_order
|
||||
and not self.doc.subcontracting_inward_order
|
||||
and not self.doc.production_plan_sub_assembly_item
|
||||
):
|
||||
return
|
||||
|
||||
warehouses = frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": self.doc.sales_order, "item_code": self.doc.production_item},
|
||||
pluck="warehouse",
|
||||
)
|
||||
if self.doc.fg_warehouse not in warehouses:
|
||||
self._throw_warehouse_not_allowed(warehouses)
|
||||
|
||||
def _throw_warehouse_not_allowed(self, warehouses):
|
||||
frappe.throw(
|
||||
_("Warehouse {0} is not allowed for Sales Order {1}, it should be {2}").format(
|
||||
self.doc.fg_warehouse, self.doc.sales_order, warehouses[0]
|
||||
),
|
||||
title=_("Target Warehouse Reservation Error"),
|
||||
)
|
||||
|
||||
def set_reserve_stock(self):
|
||||
for row in self.doc.required_items:
|
||||
row.reserve_stock = self.doc.reserve_stock
|
||||
|
||||
def enable_auto_reserve_stock(self):
|
||||
if self.doc.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
self.doc.reserve_stock = 1
|
||||
|
||||
def update_stock_reservation(self):
|
||||
self.doc.set_qty_change()
|
||||
reserve_stock_for_work_order(self.doc)
|
||||
self.doc.db_set("status", self.doc.get_status())
|
||||
|
||||
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
|
||||
names = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={
|
||||
"voucher_no": self.doc.name,
|
||||
"item_code": row.item_code,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.source_warehouse,
|
||||
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
for name in names:
|
||||
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
|
||||
|
||||
def _apply_transferred_qty(self, name, transferred_qty, row_wise_serial_batch):
|
||||
if transferred_qty < 0:
|
||||
return transferred_qty
|
||||
|
||||
doc = frappe.get_doc("Stock Reservation Entry", name)
|
||||
qty_to_update, transferred_qty = self._split_transferred_qty(doc, transferred_qty)
|
||||
if qty_to_update < 0:
|
||||
return transferred_qty
|
||||
|
||||
self._apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch)
|
||||
return transferred_qty
|
||||
|
||||
@staticmethod
|
||||
def _split_transferred_qty(doc, transferred_qty):
|
||||
if transferred_qty > flt(doc.reserved_qty - doc.consumed_qty):
|
||||
qty_to_update = doc.reserved_qty - doc.transferred_qty
|
||||
return qty_to_update, transferred_qty - qty_to_update
|
||||
return transferred_qty, 0.0
|
||||
|
||||
@staticmethod
|
||||
def _apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch):
|
||||
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
|
||||
if doc.transferred_qty >= doc.reserved_qty:
|
||||
doc.db_set("status", "Closed", update_modified=False)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
|
||||
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
|
||||
filters = {
|
||||
"voucher_no": self.doc.name,
|
||||
"item_code": item.item_code,
|
||||
"voucher_detail_no": item.name,
|
||||
"warehouse": wip_warehouse,
|
||||
"docstatus": 1,
|
||||
}
|
||||
if not self.doc.skip_transfer:
|
||||
filters["from_voucher_no"] = ("is", "set")
|
||||
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, "Manufacture")
|
||||
names = frappe.get_all("Stock Reservation Entry", filters=filters, pluck="name", order_by="creation")
|
||||
for name in names:
|
||||
consumed_qty = self._apply_consumed_qty(name, consumed_qty, row_wise_serial_batch)
|
||||
|
||||
@staticmethod
|
||||
def _apply_consumed_qty(name, consumed_qty, row_wise_serial_batch):
|
||||
consumed_qty = max(consumed_qty, 0)
|
||||
doc = frappe.get_doc("Stock Reservation Entry", name)
|
||||
qty_to_update = consumed_qty if consumed_qty < doc.reserved_qty else doc.reserved_qty
|
||||
if qty_to_update >= 0:
|
||||
doc.db_set("consumed_qty", flt(qty_to_update), update_modified=False)
|
||||
consumed_qty -= qty_to_update
|
||||
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
return consumed_qty
|
||||
|
||||
def validate_reserved_qty(self):
|
||||
sre_details = get_sre_details(self.doc.name)
|
||||
for item in self.doc.required_items:
|
||||
if details := sre_details.get(item.name):
|
||||
if details.reserved_qty < details.consumed_qty:
|
||||
frappe.throw(
|
||||
_("Consumed Qty cannot be greater than Reserved Qty for item {0}").format(
|
||||
details.consumed_qty, details.reserved_qty, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def set_reserved_qty_for_wip_and_fg(self, stock_entry):
|
||||
if stock_entry.is_return:
|
||||
return
|
||||
|
||||
stock_entry.reload()
|
||||
items = self._reservation_items_for(stock_entry)
|
||||
if not items:
|
||||
return
|
||||
|
||||
reserve_stock_for_work_order(self.doc, list(items.values()), is_transfer=False, notify=True)
|
||||
|
||||
def _reservation_items_for(self, stock_entry):
|
||||
is_finished_good = stock_entry.purpose == "Manufacture" and (
|
||||
self.doc.sales_order
|
||||
or self.doc.production_plan_sub_assembly_item
|
||||
or self.doc.subcontracting_inward_order
|
||||
or stock_entry.job_card
|
||||
)
|
||||
if is_finished_good:
|
||||
return self.get_finished_goods_for_reservation(stock_entry)
|
||||
if stock_entry.purpose == "Material Transfer for Manufacture":
|
||||
return self.get_list_of_materials_for_reservation(stock_entry)
|
||||
return frappe._dict()
|
||||
|
||||
def get_list_of_materials_for_reservation(self, stock_entry):
|
||||
items = frappe._dict()
|
||||
voucher_detail_no = {d.item_code: d.name for d in self.doc.required_items}
|
||||
|
||||
for row in stock_entry.items:
|
||||
if row.item_code not in items:
|
||||
items[row.item_code] = self._material_reservation_row(stock_entry, row, voucher_detail_no)
|
||||
else:
|
||||
items[row.item_code]["stock_qty"] += row.transfer_qty
|
||||
if row.serial_and_batch_bundle:
|
||||
items[row.item_code]["serial_and_batch_bundles"].append(row.serial_and_batch_bundle)
|
||||
|
||||
return items
|
||||
|
||||
def _material_reservation_row(self, stock_entry, row, voucher_detail_no):
|
||||
return frappe._dict(
|
||||
{
|
||||
"voucher_no": self.doc.name,
|
||||
"voucher_type": self.doc.doctype,
|
||||
"voucher_detail_no": voucher_detail_no.get(row.item_code),
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.t_warehouse,
|
||||
"stock_qty": row.transfer_qty,
|
||||
"from_voucher_no": stock_entry.name,
|
||||
"from_voucher_type": stock_entry.doctype,
|
||||
"from_voucher_detail_no": row.name,
|
||||
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
|
||||
}
|
||||
)
|
||||
|
||||
def get_finished_goods_for_reservation(self, stock_entry):
|
||||
item_details = self._finished_goods_item_details(stock_entry)
|
||||
if item_details is None:
|
||||
return
|
||||
|
||||
items = frappe._dict()
|
||||
for item in item_details:
|
||||
self._reserve_finished_good(items, item, stock_entry)
|
||||
return items
|
||||
|
||||
def _finished_goods_item_details(self, stock_entry):
|
||||
if self.doc.production_plan_sub_assembly_item:
|
||||
# Reserve the sub-assembly item for the final product for the work order.
|
||||
return self.get_wo_details()
|
||||
if self.doc.subcontracting_inward_order:
|
||||
return self.get_scio_details()
|
||||
if stock_entry.job_card:
|
||||
# Reserve the final product for the job card.
|
||||
finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good")
|
||||
if finished_good == self.doc.production_item:
|
||||
return None
|
||||
return self.get_items_to_reserve_for_job_card(stock_entry, finished_good)
|
||||
# Reserve the final product for the sales order.
|
||||
return self.get_so_details()
|
||||
|
||||
def _reserve_finished_good(self, items, item, stock_entry):
|
||||
qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty)
|
||||
if qty_to_reserve <= 0:
|
||||
return
|
||||
|
||||
warehouse = self._reservation_warehouse(item)
|
||||
for row in stock_entry.items:
|
||||
if not self._is_reservable_fg_row(row, item, warehouse):
|
||||
continue
|
||||
|
||||
reserved_qty = min(qty_to_reserve, row.transfer_qty)
|
||||
qty_to_reserve = qty_to_reserve - row.transfer_qty if qty_to_reserve > row.transfer_qty else 0
|
||||
if row.item_code not in items:
|
||||
items[row.item_code] = self._fg_reservation_row(item, row, reserved_qty, stock_entry)
|
||||
else:
|
||||
items[row.item_code]["stock_qty"] += reserved_qty
|
||||
|
||||
@staticmethod
|
||||
def _is_reservable_fg_row(row, item, warehouse):
|
||||
return bool(
|
||||
row.t_warehouse
|
||||
and row.is_finished_item
|
||||
and row.t_warehouse == warehouse
|
||||
and row.item_code == item.item_code
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reservation_warehouse(item):
|
||||
if (
|
||||
item.get("parenttype") == "Work Order"
|
||||
and item.get("skip_transfer")
|
||||
and item.get("from_wip_warehouse")
|
||||
):
|
||||
return item.wip_warehouse
|
||||
return item.warehouse
|
||||
|
||||
@staticmethod
|
||||
def _fg_reservation_row(item, row, reserved_qty, stock_entry):
|
||||
return frappe._dict(
|
||||
{
|
||||
"voucher_no": item.voucher_no,
|
||||
"voucher_type": item.voucher_type,
|
||||
"voucher_detail_no": item.name,
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.t_warehouse,
|
||||
"stock_qty": reserved_qty,
|
||||
"from_voucher_no": stock_entry.name,
|
||||
"from_voucher_type": stock_entry.doctype,
|
||||
"from_voucher_detail_no": row.name,
|
||||
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
|
||||
}
|
||||
)
|
||||
|
||||
def get_items_to_reserve_for_job_card(self, stock_entry, finished_good):
|
||||
for row in stock_entry.items:
|
||||
if row.item_code == finished_good:
|
||||
return self._job_card_reservation_details(stock_entry, row, finished_good)
|
||||
return []
|
||||
|
||||
def _job_card_reservation_details(self, stock_entry, row, finished_good):
|
||||
name = frappe.db.get_value(
|
||||
"Work Order Item", {"item_code": finished_good, "parent": self.doc.name}, "name"
|
||||
)
|
||||
pending_qty = row.qty - self._reserved_qty_for_job_card(finished_good, name, row.t_warehouse)
|
||||
if pending_qty <= 0:
|
||||
return []
|
||||
|
||||
return [
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"stock_qty": pending_qty,
|
||||
"stock_reserved_qty": 0,
|
||||
"warehouse": row.t_warehouse,
|
||||
"voucher_no": stock_entry.work_order,
|
||||
"voucher_type": "Work Order",
|
||||
"name": name,
|
||||
"delivered_qty": 0,
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
def _reserved_qty_for_job_card(self, finished_good, name, warehouse):
|
||||
sres = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
fields=["reserved_qty"],
|
||||
filters={
|
||||
"voucher_no": self.doc.name,
|
||||
"item_code": finished_good,
|
||||
"voucher_detail_no": name,
|
||||
"warehouse": warehouse,
|
||||
"docstatus": 1,
|
||||
"status": "Reserved",
|
||||
},
|
||||
)
|
||||
return sum(d.reserved_qty for d in sres)
|
||||
|
||||
def get_wo_details(self):
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
item = frappe.qb.DocType("Work Order Item")
|
||||
query = (
|
||||
frappe.qb.from_(wo)
|
||||
.inner_join(item)
|
||||
.on(wo.name == item.parent)
|
||||
.select(
|
||||
item.name,
|
||||
item.required_qty.as_("stock_qty"),
|
||||
item.transferred_qty.as_("delivered_qty"),
|
||||
item.stock_reserved_qty,
|
||||
item.source_warehouse.as_("warehouse"),
|
||||
wo.wip_warehouse,
|
||||
wo.skip_transfer,
|
||||
wo.from_wip_warehouse,
|
||||
item.parenttype,
|
||||
item.item_code,
|
||||
item.parent.as_("voucher_no"),
|
||||
item.parenttype.as_("voucher_type"),
|
||||
)
|
||||
.where(self._wo_details_filter(wo, item))
|
||||
)
|
||||
return query.run(as_dict=1)
|
||||
|
||||
def _wo_details_filter(self, wo, item):
|
||||
return (
|
||||
(item.item_code == self.doc.production_item)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo.production_plan == self.doc.production_plan)
|
||||
& (IfNull(wo.production_plan_sub_assembly_item, "") != self.doc.production_plan_sub_assembly_item)
|
||||
)
|
||||
|
||||
def get_scio_details(self):
|
||||
return frappe.get_all(
|
||||
"Subcontracting Inward Order Item",
|
||||
filters={"name": self.doc.subcontracting_inward_order_item, "docstatus": 1},
|
||||
fields=_SCIO_FIELDS,
|
||||
)
|
||||
|
||||
def get_so_details(self):
|
||||
return frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": self.doc.sales_order, "item_code": self.doc.production_item, "docstatus": 1},
|
||||
fields=_SO_FIELDS,
|
||||
)
|
||||
|
||||
def get_voucher_details(self, stock_entry):
|
||||
if stock_entry.purpose == "Manufacture" and self.doc.sales_order:
|
||||
return frappe._dict({self.doc.production_item: self._so_voucher_detail()})
|
||||
return frappe._dict({d.item_code: d.name for d in self.doc.required_items})
|
||||
|
||||
def _so_voucher_detail(self):
|
||||
return frappe.db.get_value(
|
||||
"Sales Order Item",
|
||||
{
|
||||
"parent": self.doc.sales_order,
|
||||
"item_code": self.doc.production_item,
|
||||
"docstatus": 1,
|
||||
"stock_reserved_qty": 0,
|
||||
},
|
||||
["name", "stock_qty", "stock_reserved_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
def cancel_reserved_qty_for_wip_and_fg(self, ste_doc):
|
||||
for row in ste_doc.items:
|
||||
sre_list = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={"from_voucher_no": ste_doc.name, "from_voucher_detail_no": row.name, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
if sre_list:
|
||||
unreserve_stock_for_work_order(self.doc, sre_list)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_reservation_entries(
|
||||
doc: str | Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False
|
||||
):
|
||||
"""Whitelisted entry point: verify Work Order write access, then reserve stock."""
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
||||
|
||||
frappe.has_permission("Work Order", "write", doc=doc, throw=True)
|
||||
reserve_stock_for_work_order(doc, items=items, is_transfer=is_transfer, notify=notify)
|
||||
|
||||
|
||||
def reserve_stock_for_work_order(
|
||||
doc: Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False
|
||||
):
|
||||
"""Reserve (or transfer/cancel) stock for a Work Order. Internal: no permission check.
|
||||
|
||||
Called both by the whitelisted entry point above and from the Work Order /
|
||||
Stock Entry lifecycle, where the triggering user may legitimately lack direct
|
||||
Work Order write access.
|
||||
"""
|
||||
is_transfer = cint(is_transfer)
|
||||
if items and isinstance(items, str):
|
||||
items = parse_json(items)
|
||||
|
||||
sre = StockReservation(doc, items=items)
|
||||
if doc.docstatus == 2 or doc.status == "Closed":
|
||||
sre.cancel_stock_reservation_entries()
|
||||
elif doc.docstatus == 1:
|
||||
_reserve_or_transfer(sre, doc, is_transfer)
|
||||
|
||||
doc.reload()
|
||||
doc.db_set("status", doc.get_status())
|
||||
|
||||
|
||||
def _reserve_or_transfer(sre, doc, is_transfer):
|
||||
if doc.production_plan and is_transfer:
|
||||
sre.transfer_reservation_entries_to(
|
||||
doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order"
|
||||
)
|
||||
elif doc.subcontracting_inward_order and is_transfer:
|
||||
sre.transfer_reservation_entries_to(
|
||||
doc.subcontracting_inward_order,
|
||||
from_doctype="Subcontracting Inward Order",
|
||||
to_doctype="Work Order",
|
||||
against_fg_item=doc.subcontracting_inward_order_item,
|
||||
qty_change=doc.qty_change,
|
||||
)
|
||||
elif sre.make_stock_reservation_entries():
|
||||
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_stock_reservation_entries(doc: str | dict, sre_list: str | list):
|
||||
"""Whitelisted entry point: verify Work Order write access, then cancel reservations."""
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
||||
|
||||
frappe.has_permission("Work Order", "write", doc=doc, throw=True)
|
||||
unreserve_stock_for_work_order(doc, sre_list)
|
||||
|
||||
|
||||
def unreserve_stock_for_work_order(doc: Document, sre_list: str | list):
|
||||
"""Cancel stock reservation entries for a Work Order. Internal: no permission check."""
|
||||
sre = StockReservation(doc)
|
||||
sre.cancel_stock_reservation_entries(sre_list)
|
||||
|
||||
doc.reload()
|
||||
doc.db_set("status", doc.get_status())
|
||||
|
||||
|
||||
def get_sre_details(work_order):
|
||||
sre_details = frappe._dict()
|
||||
data = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={"voucher_no": work_order, "docstatus": 1},
|
||||
fields=[
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"reserved_qty",
|
||||
"transferred_qty",
|
||||
"consumed_qty",
|
||||
"voucher_detail_no",
|
||||
],
|
||||
)
|
||||
for row in data:
|
||||
_accumulate_sre_details(sre_details, row)
|
||||
return sre_details
|
||||
|
||||
|
||||
def _accumulate_sre_details(sre_details, row):
|
||||
existing = sre_details.get(row.voucher_detail_no)
|
||||
if not existing:
|
||||
sre_details[row.voucher_detail_no] = row
|
||||
return
|
||||
|
||||
existing.reserved_qty += row.reserved_qty
|
||||
existing.transferred_qty += row.transferred_qty
|
||||
existing.consumed_qty += row.consumed_qty
|
||||
|
||||
|
||||
def get_consumed_qty(work_order, item_code):
|
||||
stock_entry = frappe.qb.DocType("Stock Entry")
|
||||
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
|
||||
result = (
|
||||
frappe.qb.from_(stock_entry)
|
||||
.inner_join(stock_entry_detail)
|
||||
.on(stock_entry_detail.parent == stock_entry.name)
|
||||
.select(fn.Sum(stock_entry_detail.transfer_qty).as_("qty"))
|
||||
.where(_consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code))
|
||||
).run()
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
|
||||
def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code):
|
||||
return (
|
||||
(stock_entry.work_order == work_order)
|
||||
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
& ((stock_entry_detail.item_code == item_code) | (stock_entry_detail.original_item == item_code))
|
||||
)
|
||||
|
||||
|
||||
def get_reserved_qty_for_production(
|
||||
item_code: str,
|
||||
warehouse: str,
|
||||
non_completed_production_plans: list | None = None,
|
||||
check_production_plan: bool = False,
|
||||
) -> float:
|
||||
"""Get total reserved quantity for any item in specified warehouse"""
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
qty_field = wo_item.required_qty if check_production_plan else _production_reserved_qty_field(wo, wo_item)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(wo)
|
||||
.from_(wo_item)
|
||||
.select(Sum(qty_field))
|
||||
.where(
|
||||
(wo_item.item_code == item_code)
|
||||
& (wo_item.parent == wo.name)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo_item.source_warehouse == warehouse)
|
||||
)
|
||||
)
|
||||
query = _apply_production_plan_filter(
|
||||
query, wo, wo_item, check_production_plan, non_completed_production_plans
|
||||
)
|
||||
return query.run()[0][0] or 0.0
|
||||
|
||||
|
||||
def _production_reserved_qty_field(wo, wo_item):
|
||||
qty_field = Case()
|
||||
qty_field = qty_field.when(
|
||||
(wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty), 0.0
|
||||
)
|
||||
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
|
||||
return qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
|
||||
|
||||
|
||||
def _apply_production_plan_filter(query, wo, wo_item, check_production_plan, non_completed_production_plans):
|
||||
if check_production_plan:
|
||||
query = query.where(wo.production_plan.isnotnull())
|
||||
else:
|
||||
query = query.where(
|
||||
(wo.status.notin(["Stopped", "Completed", "Closed"]))
|
||||
& (
|
||||
(wo_item.required_qty > wo_item.transferred_qty)
|
||||
| (wo_item.required_qty > wo_item.consumed_qty)
|
||||
)
|
||||
)
|
||||
|
||||
if non_completed_production_plans:
|
||||
query = query.where(wo.production_plan.isin(non_completed_production_plans))
|
||||
return query
|
||||
|
||||
|
||||
def get_row_wise_serial_batch(work_order, purpose=None):
|
||||
purpose = purpose or "Material Transfer for Manufacture"
|
||||
stock_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={"work_order": work_order, "purpose": purpose, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
row_wise_serial_batch = {}
|
||||
for entry in _serial_batch_entries(stock_entries):
|
||||
_accumulate_serial_batch(row_wise_serial_batch, entry)
|
||||
return row_wise_serial_batch
|
||||
|
||||
|
||||
def _serial_batch_entries(stock_entries):
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
fields=_SERIAL_BATCH_FIELDS,
|
||||
filters=[
|
||||
["Serial and Batch Bundle", "voucher_type", "=", "Stock Entry"],
|
||||
["Serial and Batch Bundle", "voucher_no", "in", stock_entries],
|
||||
["Serial and Batch Bundle", "voucher_detail_no", "is", "set"],
|
||||
["Serial and Batch Bundle", "docstatus", "<", 2],
|
||||
["Serial and Batch Bundle", "is_cancelled", "=", 0],
|
||||
["Serial and Batch Entry", "qty", "<", 0],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _accumulate_serial_batch(row_wise_serial_batch, entry):
|
||||
key = (entry.item_code, entry.warehouse)
|
||||
details = row_wise_serial_batch.setdefault(
|
||||
key, frappe._dict({"serial_nos": [], "batch_nos": defaultdict(float)})
|
||||
)
|
||||
if entry.serial_no:
|
||||
details.serial_nos.append(entry.serial_no)
|
||||
if entry.batch_no:
|
||||
details.batch_nos[entry.batch_no] += abs(entry.qty)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user