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:
pandiyan
2026-06-30 21:49:47 +05:30
committed by Mergify
parent d9aa4491f1
commit ce01f03bde
3 changed files with 739 additions and 0 deletions

View File

@@ -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,
}

View 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]
)

View File

@@ -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 (