mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
fix: roll up process loss to the work order for semi finished goods
update_work_order_qty() returns early when track_semi_finished_goods is
enabled, so set_process_loss_qty() never ran and Work Order.process_loss_qty
stayed at zero even though the job cards and the work order operations had
booked the loss. The work order also never reached the Completed status,
since that needs produced_qty + process_loss_qty to cover the ordered qty.
Calling set_process_loss_qty() from that early return is not enough: the
final operation has no semi finished good bom, so its manufacture entry is
not from a bom, remove_fg_completed_qty() zeroes fg_completed_qty and
update_work_order_qty() is never reached at all.
The manufacture entries cannot be summed either. Each one is reset to
MAX(Work Order Operation.process_loss_qty), so every entry of a multi
operation chain carries the running maximum instead of the loss of its own
operation. Aggregate the operations instead, and refresh the work order from
the job card, which is where the operation loss is written.
(cherry picked from commit 0eb61c9fac)
# Conflicts:
# erpnext/manufacturing/doctype/job_card/job_card.py
# erpnext/manufacturing/doctype/work_order/services/status.py
This commit is contained in:
@@ -1041,7 +1041,32 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo):
|
||||
<<<<<<< HEAD
|
||||
workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate")
|
||||
=======
|
||||
time_data = self.get_operation_time_data()
|
||||
|
||||
for data in wo.operations:
|
||||
if data.get("name") == self.operation_id:
|
||||
self.update_wo_operation_row(
|
||||
data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data
|
||||
)
|
||||
|
||||
wo.flags.ignore_validate_update_after_submit = True
|
||||
wo.update_operation_status()
|
||||
wo.calculate_operating_cost()
|
||||
wo.set_actual_dates()
|
||||
|
||||
if wo.track_semi_finished_goods:
|
||||
wo.set_process_loss_qty()
|
||||
|
||||
if time_data:
|
||||
wo.status = "In Process"
|
||||
|
||||
wo.save()
|
||||
|
||||
def get_operation_time_data(self):
|
||||
>>>>>>> 0eb61c9fac (fix: roll up process loss to the work order for semi finished goods)
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
jctl = frappe.qb.DocType("Job Card Time Log")
|
||||
|
||||
|
||||
471
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
471
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# 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.docstatus == 1:
|
||||
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
|
||||
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
|
||||
# are reflected immediately, instead of only after the next status update call.
|
||||
self.doc.refresh_material_transferred_for_manufacturing()
|
||||
|
||||
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
|
||||
or self.doc.skip_transfer
|
||||
or self._has_transferred_material()
|
||||
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 _has_transferred_material(self):
|
||||
"""True if any raw material was transferred against this work order via a pick list
|
||||
or a material request (these leave material_transferred_for_manufacturing at 0 via
|
||||
the min-fraction rule)."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
mr_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
# Stock Entry only carries `material_request` at the child-row level, so a Stock
|
||||
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once
|
||||
# that's established, sum every row's transfer_qty, not just the linked ones (a
|
||||
# manually appended extra row on the same entry has no material_request of its own).
|
||||
mr_sourced_stock_entries = (
|
||||
frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull())
|
||||
)
|
||||
qty = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(
|
||||
(ste.work_order == self.doc.name)
|
||||
& (ste.docstatus == 1)
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
|
||||
)
|
||||
).run()[0][0]
|
||||
return flt(qty) > 0
|
||||
|
||||
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):
|
||||
self.doc.db_set("process_loss_qty", self._process_loss_qty())
|
||||
|
||||
def _process_loss_qty(self):
|
||||
if self.doc.track_semi_finished_goods:
|
||||
return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations))
|
||||
|
||||
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]
|
||||
|
||||
return 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]
|
||||
)
|
||||
Reference in New Issue
Block a user