From ce01f03bdea39240b0736663c98d524bdec6051b Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 30 Jun 2026 21:49:47 +0530 Subject: [PATCH] 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 d072909451dd3c12ec7f8e89793b47d008f81ffc) # 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 --- .../work_order/services/required_items.py | 297 ++++++++++++ .../doctype/work_order/services/status.py | 434 ++++++++++++++++++ .../doctype/work_order/work_order.py | 8 + 3 files changed, 739 insertions(+) create mode 100644 erpnext/manufacturing/doctype/work_order/services/required_items.py create mode 100644 erpnext/manufacturing/doctype/work_order/services/status.py diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py new file mode 100644 index 00000000000..c43d1a43e4f --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -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, + } diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py new file mode 100644 index 00000000000..ce67978afd7 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -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] + ) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index e82007755d5..c092dfb19d1 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -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 (