mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 08:28:44 +00:00
fix: recompute transferred qty before deciding work order status
work order status was decided using a stale transferred-qty value,
computed before the current stock entry's transfer got recomputed.
this left work orders stuck at "not started" for pick-list-driven
transfers, since those entries never set fg_completed_qty and their
transferred qty can only be known from actual item-level transfers.
an earlier attempt fixed this by setting fg_completed_qty from the pick
list's for_qty, but that broke two things tied to fg_completed_qty
being zero: the excess-transfer guard, and the partial-transfer
fraction logic used to avoid marking a work order as fully supplied too
early.
recompute the transferred qty first, then decide status from the fresh
value. revert the fg_completed_qty change since it's no longer needed.
(cherry picked from commit d072909451)
# Conflicts:
# erpnext/manufacturing/doctype/work_order/services/required_items.py
# erpnext/manufacturing/doctype/work_order/services/status.py
# erpnext/manufacturing/doctype/work_order/work_order.py
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
# 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.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
get_consumed_qty,
|
||||
get_row_wise_serial_batch,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.status import StatusService
|
||||
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()
|
||||
|
||||
WorkOrderStockReservation(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:
|
||||
WorkOrderStockReservation(self.doc).update_qty_in_stock_reservation(
|
||||
row, transferred_qty, row_wise_serial_batch
|
||||
)
|
||||
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def refresh_material_transferred_for_manufacturing(self):
|
||||
"""Recompute material_transferred_for_manufacturing only, without touching per-row
|
||||
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
|
||||
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
|
||||
"""
|
||||
if self.doc.skip_transfer:
|
||||
return
|
||||
transferred_items = self._material_transfer_qty_by_item(is_return=0)
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = StatusService(self.doc).get_transferred_or_manufactured_qty(
|
||||
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
|
||||
)
|
||||
if sum_fg_completed_qty:
|
||||
self.doc.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||
return
|
||||
|
||||
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||
required_by_item = {}
|
||||
for row in self.doc.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
|
||||
if not required_by_item:
|
||||
return
|
||||
|
||||
min_fraction = min(
|
||||
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
)
|
||||
min_fraction = min(min_fraction, 1.0)
|
||||
material_transferred = min_fraction * flt(self.doc.qty)
|
||||
self.doc.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||
|
||||
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)
|
||||
# original_item becomes the output dict key below, so it must stay coherent per row: the
|
||||
# same item_code can be transferred both for itself (original_item NULL) and as a substitute
|
||||
# for another required item (original_item set). Max() over a single item_code group could
|
||||
# pick the substitute's original_item and misattribute the item's own transfer to it. Group
|
||||
# by (item_code, original_item) so each pair sums separately, then accumulate into the keyed
|
||||
# dict (two distinct rows can resolve to the same key, e.g. A's own transfer and B-for-A).
|
||||
.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, ste_child.original_item)
|
||||
)
|
||||
qty_by_item = frappe._dict()
|
||||
for d in query.run(as_dict=1) or []:
|
||||
key = d.original_item or d.item_code
|
||||
qty_by_item[key] = (qty_by_item.get(key) or 0.0) + flt(d.qty)
|
||||
return qty_by_item
|
||||
|
||||
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
|
||||
WorkOrderStockReservation(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,
|
||||
}
|
||||
434
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
434
erpnext/manufacturing/doctype/work_order/services/status.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# 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
|
||||
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]
|
||||
)
|
||||
@@ -1963,10 +1963,18 @@ class WorkOrder(Document):
|
||||
# Reserve the final product for the sales order.
|
||||
item_details = self.get_so_details()
|
||||
|
||||
<<<<<<< HEAD
|
||||
for item in item_details:
|
||||
qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty)
|
||||
if qty_to_reserve <= 0:
|
||||
continue
|
||||
=======
|
||||
def refresh_material_transferred_for_manufacturing(self):
|
||||
return RequiredItemsService(self).refresh_material_transferred_for_manufacturing()
|
||||
|
||||
def update_returned_qty(self):
|
||||
return RequiredItemsService(self).update_returned_qty()
|
||||
>>>>>>> d072909451 (fix: recompute transferred qty before deciding work order status)
|
||||
|
||||
warehouse = item.warehouse
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user