mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 15:29:30 +00:00
fix(manufacturing): account for process loss in Production Plan Work Orders (backport #58799) (#58838)
This commit is contained in:
@@ -31,6 +31,9 @@ from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_i
|
|||||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||||
get_mins_between_operations,
|
get_mins_between_operations,
|
||||||
)
|
)
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
|
||||||
|
ProductionPlanWorkOrderQuantities,
|
||||||
|
)
|
||||||
from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations
|
from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations
|
||||||
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
|
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
|
||||||
get_subcontracting_boms_for_finished_goods,
|
get_subcontracting_boms_for_finished_goods,
|
||||||
@@ -990,6 +993,10 @@ class JobCard(Document):
|
|||||||
if not self.operation_id:
|
if not self.operation_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
work_order = frappe.get_doc("Work Order", self.work_order)
|
||||||
|
if work_order.production_plan:
|
||||||
|
ProductionPlanWorkOrderQuantities(work_order.production_plan).lock_plan_row(work_order)
|
||||||
|
|
||||||
job_cards = frappe.get_all(
|
job_cards = frappe.get_all(
|
||||||
"Job Card",
|
"Job Card",
|
||||||
filters={
|
filters={
|
||||||
@@ -1004,14 +1011,13 @@ class JobCard(Document):
|
|||||||
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
|
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
|
||||||
|
|
||||||
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
|
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
|
||||||
if (
|
if self.finished_good and work_order.production_item == self.finished_good:
|
||||||
self.finished_good
|
work_order.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
|
||||||
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
|
if work_order.production_plan:
|
||||||
== self.finished_good
|
ProductionPlanWorkOrderQuantities(work_order.production_plan).validate_work_order(
|
||||||
):
|
work_order, process_loss_qty=work_order.process_loss_qty
|
||||||
_wo_doc = frappe.get_doc("Work Order", self.work_order)
|
)
|
||||||
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
|
work_order.db_set("status", work_order.get_status())
|
||||||
_wo_doc.db_set("status", _wo_doc.get_status())
|
|
||||||
|
|
||||||
def update_corrective_in_work_order(self, wo):
|
def update_corrective_in_work_order(self, wo):
|
||||||
wo.corrective_operation_cost = 0.0
|
wo.corrective_operation_cost = 0.0
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ frappe.ui.form.on("Production Plan", {
|
|||||||
|
|
||||||
let has_items =
|
let has_items =
|
||||||
items.filter((item) => {
|
items.filter((item) => {
|
||||||
|
const reference_field =
|
||||||
|
item.doctype === "Production Plan Item"
|
||||||
|
? "production_plan_item"
|
||||||
|
: "production_plan_sub_assembly_item";
|
||||||
|
const pending_qty = frm.doc.__onload?.pending_work_order_qty?.[reference_field]?.[item.name];
|
||||||
|
if (pending_qty !== undefined) {
|
||||||
|
return pending_qty > 0;
|
||||||
|
}
|
||||||
if (item.planned_qty) {
|
if (item.planned_qty) {
|
||||||
return item.planned_qty > item.ordered_qty;
|
return item.planned_qty > item.ordered_qty;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ from pypika.terms import ExistsCriterion
|
|||||||
|
|
||||||
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
||||||
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
|
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
|
||||||
|
ProductionPlanWorkOrderQuantities,
|
||||||
|
)
|
||||||
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
||||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||||
@@ -122,6 +125,12 @@ class ProductionPlan(Document):
|
|||||||
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
|
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.docstatus == 1:
|
||||||
|
self.set_onload(
|
||||||
|
"pending_work_order_qty",
|
||||||
|
ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self),
|
||||||
|
)
|
||||||
|
|
||||||
def on_discard(self):
|
def on_discard(self):
|
||||||
self.db_set("status", "Cancelled")
|
self.db_set("status", "Cancelled")
|
||||||
|
|
||||||
@@ -728,6 +737,7 @@ class ProductionPlan(Document):
|
|||||||
|
|
||||||
def get_production_items(self):
|
def get_production_items(self):
|
||||||
item_dict = {}
|
item_dict = {}
|
||||||
|
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
|
||||||
|
|
||||||
for d in self.po_items:
|
for d in self.po_items:
|
||||||
item_details = {
|
item_details = {
|
||||||
@@ -750,29 +760,12 @@ class ProductionPlan(Document):
|
|||||||
"project": self.project,
|
"project": self.project,
|
||||||
}
|
}
|
||||||
|
|
||||||
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
|
|
||||||
if self.combine_items:
|
|
||||||
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
|
|
||||||
|
|
||||||
if not d.sales_order:
|
|
||||||
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
|
|
||||||
|
|
||||||
if not item_details["project"] and d.sales_order:
|
if not item_details["project"] and d.sales_order:
|
||||||
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
|
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
|
||||||
|
|
||||||
if self.get_items_from == "Material Request":
|
item_details["qty"] = pending["production_plan_item"][d.name]
|
||||||
item_details.update({"qty": d.planned_qty})
|
# A Work Order can reference only one Production Plan row.
|
||||||
item_dict[
|
item_dict[d.name] = item_details
|
||||||
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
|
|
||||||
] = item_details
|
|
||||||
else:
|
|
||||||
item_details.update(
|
|
||||||
{
|
|
||||||
"qty": flt(item_dict.get(key, {}).get("qty"))
|
|
||||||
+ (flt(d.planned_qty) - flt(d.ordered_qty))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
item_dict[key] = item_details
|
|
||||||
|
|
||||||
return item_dict
|
return item_dict
|
||||||
|
|
||||||
@@ -780,6 +773,7 @@ class ProductionPlan(Document):
|
|||||||
def make_work_order(self):
|
def make_work_order(self):
|
||||||
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
|
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
|
||||||
|
|
||||||
|
self.reload()
|
||||||
wo_list, po_list = [], []
|
wo_list, po_list = [], []
|
||||||
subcontracted_po = {}
|
subcontracted_po = {}
|
||||||
default_warehouses = get_default_warehouse(self.company)
|
default_warehouses = get_default_warehouse(self.company)
|
||||||
@@ -809,6 +803,7 @@ class ProductionPlan(Document):
|
|||||||
wo_list.append(work_order)
|
wo_list.append(work_order)
|
||||||
|
|
||||||
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
|
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
|
||||||
|
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
|
||||||
for row in self.sub_assembly_items:
|
for row in self.sub_assembly_items:
|
||||||
if row.type_of_manufacturing == "Subcontract":
|
if row.type_of_manufacturing == "Subcontract":
|
||||||
subcontracted_po.setdefault(row.supplier, []).append(row)
|
subcontracted_po.setdefault(row.supplier, []).append(row)
|
||||||
@@ -825,10 +820,9 @@ class ProductionPlan(Document):
|
|||||||
"company": self.get("company"),
|
"company": self.get("company"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if flt(row.qty) <= flt(row.ordered_qty):
|
self.prepare_data_for_sub_assembly_items(
|
||||||
continue
|
row, work_order_data, pending["production_plan_sub_assembly_item"][row.name]
|
||||||
|
)
|
||||||
self.prepare_data_for_sub_assembly_items(row, work_order_data)
|
|
||||||
|
|
||||||
if work_order_data.get("qty") <= 0:
|
if work_order_data.get("qty") <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -837,7 +831,7 @@ class ProductionPlan(Document):
|
|||||||
if work_order:
|
if work_order:
|
||||||
wo_list.append(work_order)
|
wo_list.append(work_order)
|
||||||
|
|
||||||
def prepare_data_for_sub_assembly_items(self, row, wo_data):
|
def prepare_data_for_sub_assembly_items(self, row, wo_data, pending_qty=None):
|
||||||
for field in [
|
for field in [
|
||||||
"production_item",
|
"production_item",
|
||||||
"item_name",
|
"item_name",
|
||||||
@@ -853,7 +847,11 @@ class ProductionPlan(Document):
|
|||||||
if row.get(field):
|
if row.get(field):
|
||||||
wo_data[field] = row.get(field)
|
wo_data[field] = row.get(field)
|
||||||
|
|
||||||
wo_data["qty"] = flt(row.get("qty")) - flt(row.get("ordered_qty"))
|
if pending_qty is None:
|
||||||
|
pending_qty = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)[
|
||||||
|
"production_plan_sub_assembly_item"
|
||||||
|
][row.name]
|
||||||
|
wo_data["qty"] = pending_qty
|
||||||
|
|
||||||
wo_data.update(
|
wo_data.update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1567,12 +1567,12 @@ class TestProductionPlan(ERPNextTestSuite):
|
|||||||
def test_multiple_work_order_for_production_plan_item(self):
|
def test_multiple_work_order_for_production_plan_item(self):
|
||||||
"Test producing Prod Plan (making WO) in parts."
|
"Test producing Prod Plan (making WO) in parts."
|
||||||
|
|
||||||
def create_work_order(item, pln, qty):
|
def create_work_order(pln, qty):
|
||||||
# Get Production Items
|
# Get Production Items
|
||||||
items_data = pln.get_production_items()
|
items_data = pln.get_production_items()
|
||||||
|
|
||||||
# Update qty
|
# Update qty
|
||||||
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty
|
items_data[pln.po_items[0].name]["qty"] = qty
|
||||||
|
|
||||||
# Create and Submit Work Order for each item in items_data
|
# Create and Submit Work Order for each item in items_data
|
||||||
for _key, item in items_data.items():
|
for _key, item in items_data.items():
|
||||||
@@ -1600,17 +1600,17 @@ class TestProductionPlan(ERPNextTestSuite):
|
|||||||
wo_list = []
|
wo_list = []
|
||||||
|
|
||||||
# Create and Submit 1st Work Order for 3 qty
|
# Create and Submit 1st Work Order for 3 qty
|
||||||
create_work_order(item, pln, 3)
|
create_work_order(pln, 3)
|
||||||
pln.reload()
|
pln.reload()
|
||||||
self.assertEqual(pln.po_items[0].ordered_qty, 3)
|
self.assertEqual(pln.po_items[0].ordered_qty, 3)
|
||||||
|
|
||||||
# Create and Submit 2nd Work Order for 2 qty
|
# Create and Submit 2nd Work Order for 2 qty
|
||||||
create_work_order(item, pln, 2)
|
create_work_order(pln, 2)
|
||||||
pln.reload()
|
pln.reload()
|
||||||
self.assertEqual(pln.po_items[0].ordered_qty, 5)
|
self.assertEqual(pln.po_items[0].ordered_qty, 5)
|
||||||
|
|
||||||
# Overproduction
|
# Overproduction
|
||||||
self.assertRaises(OverProductionError, create_work_order, item=item, pln=pln, qty=2)
|
self.assertRaises(OverProductionError, create_work_order, pln=pln, qty=2)
|
||||||
|
|
||||||
# Cancel 1st Work Order
|
# Cancel 1st Work Order
|
||||||
wo1 = frappe.get_doc("Work Order", wo_list[0])
|
wo1 = frappe.get_doc("Work Order", wo_list[0])
|
||||||
@@ -1791,8 +1791,11 @@ class TestProductionPlan(ERPNextTestSuite):
|
|||||||
make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4)
|
make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4)
|
||||||
|
|
||||||
# Step - 1: Create Production Plan
|
# Step - 1: Create Production Plan
|
||||||
pln = create_production_plan(item_code=fg_item, planned_qty=5, skip_getting_mr_items=1)
|
pln = create_production_plan(
|
||||||
|
item_code=fg_item, planned_qty=5, skip_getting_mr_items=1, do_not_submit=1
|
||||||
|
)
|
||||||
pln.get_sub_assembly_items()
|
pln.get_sub_assembly_items()
|
||||||
|
pln.submit()
|
||||||
|
|
||||||
# Step - 2: Create Work Orders
|
# Step - 2: Create Work Orders
|
||||||
pln.make_work_order()
|
pln.make_work_order()
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
|
||||||
|
create_production_plan,
|
||||||
|
make_bom,
|
||||||
|
)
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
|
||||||
|
ProductionPlanWorkOrderQuantities,
|
||||||
|
)
|
||||||
|
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||||
|
OverProductionError,
|
||||||
|
StockOverProductionError,
|
||||||
|
close_work_order,
|
||||||
|
stop_unstop,
|
||||||
|
)
|
||||||
|
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo
|
||||||
|
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||||
|
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
REFERENCE_FIELDS = ("production_plan_item", "production_plan_sub_assembly_item")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionPlanWorkOrderQuantities(ERPNextTestSuite):
|
||||||
|
def setUp(self):
|
||||||
|
self.warehouse = "_Test Warehouse - _TC"
|
||||||
|
self.raw_material, self.sub_assembly, self.finished_good = (
|
||||||
|
make_item(properties={"is_stock_item": 1, "stock_uom": "Kg", "valuation_rate": 10}).name
|
||||||
|
for _ in range(3)
|
||||||
|
)
|
||||||
|
for item, material in (
|
||||||
|
(self.sub_assembly, self.raw_material),
|
||||||
|
(self.finished_good, self.sub_assembly),
|
||||||
|
):
|
||||||
|
bom = make_bom(item=item, raw_materials=[material], do_not_save=True)
|
||||||
|
bom.process_loss_percentage = 10
|
||||||
|
bom.insert().submit()
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 0)
|
||||||
|
|
||||||
|
def test_quantity_limit_on_submit(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.qty = 50
|
||||||
|
first.submit()
|
||||||
|
second = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(second.qty, 50)
|
||||||
|
self.assert_overproduction(second, 60)
|
||||||
|
second.qty = 50
|
||||||
|
second.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_recorded_loss_creates_replacement(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
manufacture = self.manufacture_with_loss(first)
|
||||||
|
first.reload()
|
||||||
|
self.assertEqual(first.process_loss_qty, 10)
|
||||||
|
self.assertEqual(first.produced_qty, 90)
|
||||||
|
self.assert_pending_qty(plan, field, 10)
|
||||||
|
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(replacement.qty, 10)
|
||||||
|
self.assert_overproduction(replacement, 11)
|
||||||
|
replacement.qty = 10
|
||||||
|
replacement.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
row = self.plan_row(plan, field)
|
||||||
|
self.assertEqual(row.ordered_qty, 110)
|
||||||
|
|
||||||
|
self.assert_loss_reversal_blocked(manufacture)
|
||||||
|
first.reload()
|
||||||
|
self.assertEqual(first.process_loss_qty, 10)
|
||||||
|
self.assertEqual(first.produced_qty, 90)
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
replacement.cancel()
|
||||||
|
manufacture.cancel()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 0)
|
||||||
|
first.reload().cancel()
|
||||||
|
self.assert_pending_qty(plan, field, 100)
|
||||||
|
|
||||||
|
def test_cumulative_manufacture_loss_exceeds_work_order(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in (None, *REFERENCE_FIELDS):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field or "production_plan_item")
|
||||||
|
if field is None:
|
||||||
|
first.production_plan = None
|
||||||
|
first.production_plan_item = None
|
||||||
|
first.qty = 100
|
||||||
|
first.submit()
|
||||||
|
self.manufacture_with_loss(first, loss_qty=99)
|
||||||
|
second_entry = self.manufacture_with_loss(first, loss_qty=99, submit=False)
|
||||||
|
self.assert_manufacture_rejected(second_entry, StockOverProductionError)
|
||||||
|
self.assertEqual(first.reload().produced_qty, 1)
|
||||||
|
self.assertEqual(first.process_loss_qty, 99)
|
||||||
|
if field:
|
||||||
|
self.assert_pending_qty(plan, field, 99)
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
replacement.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_cumulative_manufacture_loss_respects_allowance(self):
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
self.manufacture_with_loss(first, qty=50)
|
||||||
|
self.manufacture_with_loss(first, qty=50)
|
||||||
|
last_entry = self.manufacture_with_loss(first, qty=10)
|
||||||
|
self.assertEqual(first.reload().produced_qty, 99)
|
||||||
|
self.assertEqual(first.process_loss_qty, 11)
|
||||||
|
self.assert_pending_qty(plan, field, 1)
|
||||||
|
excess = self.manufacture_with_loss(first, qty=1, submit=False)
|
||||||
|
self.assert_manufacture_rejected(excess, StockOverProductionError)
|
||||||
|
excess.delete()
|
||||||
|
last_entry.cancel()
|
||||||
|
self.assertEqual(first.reload().produced_qty, 90)
|
||||||
|
self.assertEqual(first.process_loss_qty, 10)
|
||||||
|
self.manufacture_with_loss(first, qty=10)
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("System Settings", {"float_precision": 6})
|
||||||
|
def test_cumulative_fractional_manufacture_loss(self):
|
||||||
|
plan = self.make_plan(qty=0.3)
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
self.manufacture_with_loss(first, qty=0.1, loss_qty=0.025)
|
||||||
|
self.manufacture_with_loss(first, qty=0.2, loss_qty=0.05)
|
||||||
|
self.assertAlmostEqual(first.reload().produced_qty, 0.225)
|
||||||
|
self.assertAlmostEqual(first.process_loss_qty, 0.075)
|
||||||
|
excess = self.manufacture_with_loss(first, qty=0.001, submit=False)
|
||||||
|
self.assert_manufacture_rejected(excess, StockOverProductionError)
|
||||||
|
|
||||||
|
def test_existing_excess_loss_preserves_produced_quantity(self):
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
for loss_qty, produced_qty in ((198, 2), (100, 2), (20, 90), (198, 0)):
|
||||||
|
with self.subTest(field=field, loss_qty=loss_qty, produced_qty=produced_qty):
|
||||||
|
plan = self.make_plan()
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
# Reproduce records saved before cumulative manufacture validation existed.
|
||||||
|
first.db_set({"process_loss_qty": loss_qty, "produced_qty": produced_qty})
|
||||||
|
pending_qty = 100 - produced_qty
|
||||||
|
self.assert_pending_qty(plan, field, pending_qty)
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(replacement.qty, pending_qty)
|
||||||
|
self.assert_overproduction(replacement, pending_qty + 1)
|
||||||
|
replacement.qty = pending_qty
|
||||||
|
replacement.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
quantities = ProductionPlanWorkOrderQuantities(plan.name)
|
||||||
|
quantities.validate_work_order(first, process_loss_qty=loss_qty)
|
||||||
|
with self.assertRaises(OverProductionError):
|
||||||
|
quantities.validate_work_order(first, process_loss_qty=0)
|
||||||
|
|
||||||
|
def test_more_production_cannot_consume_replacement_allowance(self):
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
self.manufacture_with_loss(first, loss_qty=99)
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
replacement.qty = 109
|
||||||
|
replacement.submit()
|
||||||
|
# This fits the first Work Order's allowance, but exceeds the plan's 110 units.
|
||||||
|
excess = self.manufacture_with_loss(first, qty=10, loss_qty=9, submit=False)
|
||||||
|
self.assert_manufacture_rejected(excess, OverProductionError)
|
||||||
|
self.assertEqual(first.reload().produced_qty, 1)
|
||||||
|
self.assertEqual(first.process_loss_qty, 99)
|
||||||
|
|
||||||
|
def test_loss_reversal_with_draft_replacement(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
manufacture = self.manufacture_with_loss(first)
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
manufacture.cancel()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 0)
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
self.assert_overproduction(replacement, 10)
|
||||||
|
|
||||||
|
def test_partial_loss_reversal_with_overproduction_allowance(self):
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 5)
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
manufactures = [self.manufacture_with_loss(first, qty=50) for _ in range(2)]
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 10)
|
||||||
|
replacement = self.create_work_order(plan, field)
|
||||||
|
replacement.submit()
|
||||||
|
|
||||||
|
# Retaining five units of loss keeps the net quantity at the allowed 105.
|
||||||
|
manufactures[1].cancel()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 5)
|
||||||
|
self.assert_loss_reversal_blocked(manufactures[0])
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 5)
|
||||||
|
replacement.cancel()
|
||||||
|
manufactures[0].cancel()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 0)
|
||||||
|
|
||||||
|
def test_job_card_loss_reversal_with_replacement(self):
|
||||||
|
self.make_bom_with_operation(self.finished_good, self.raw_material)
|
||||||
|
plan = self.make_plan()
|
||||||
|
first = self.create_work_order(plan, "production_plan_item")
|
||||||
|
first.submit()
|
||||||
|
job_card = frappe.get_last_doc("Job Card", {"work_order": first.name})
|
||||||
|
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
|
||||||
|
job_card.save()
|
||||||
|
job_card.complete_job_card(
|
||||||
|
qty=90,
|
||||||
|
for_quantity=100,
|
||||||
|
pending_qty=0,
|
||||||
|
process_loss_qty=10,
|
||||||
|
end_time="2024-05-01 09:00:00",
|
||||||
|
)
|
||||||
|
job_card.reload().submit()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 10)
|
||||||
|
make_stock_entry(item_code=self.raw_material, target=self.warehouse, qty=100, basic_rate=10)
|
||||||
|
manufacture = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||||
|
replacement = self.create_work_order(plan, "production_plan_item")
|
||||||
|
replacement.submit()
|
||||||
|
with self.assert_plan_locked_before_work_order_update(first):
|
||||||
|
manufacture.cancel()
|
||||||
|
job_card.reload()
|
||||||
|
self.assert_loss_reversal_blocked(job_card)
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 10)
|
||||||
|
self.assertEqual(first.operations[0].process_loss_qty, 10)
|
||||||
|
replacement.cancel()
|
||||||
|
job_card.cancel()
|
||||||
|
self.assertEqual(first.reload().process_loss_qty, 0)
|
||||||
|
|
||||||
|
def test_expected_loss_does_not_allow_extra_quantity(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
work_order = self.create_work_order(plan, field)
|
||||||
|
self.assert_overproduction(work_order, 110)
|
||||||
|
|
||||||
|
def test_overproduction_allowance(self):
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(first.qty, 100)
|
||||||
|
first.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
second = self.copy_work_order(first)
|
||||||
|
self.assert_overproduction(second, 11)
|
||||||
|
second.qty = 10
|
||||||
|
second.submit()
|
||||||
|
|
||||||
|
def test_drafts_and_cancelled_orders_do_not_consume_quantity(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
second = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(first.qty, second.qty)
|
||||||
|
first.submit()
|
||||||
|
self.assert_overproduction(second, 100)
|
||||||
|
first.cancel()
|
||||||
|
second.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_process_loss_and_overproduction_allowance(self):
|
||||||
|
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.qty = 50
|
||||||
|
first.submit()
|
||||||
|
self.manufacture_with_loss(first)
|
||||||
|
second = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(second.qty, 55)
|
||||||
|
self.assert_overproduction(second, 66)
|
||||||
|
second.qty = 65
|
||||||
|
second.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_stopped_and_closed_orders_consume_quantity(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.submit()
|
||||||
|
stop_unstop(first.name, "Stopped")
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
stop_unstop(first.name, "Not Started")
|
||||||
|
close_work_order(first.name, "Closed")
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_fractional_quantities(self):
|
||||||
|
plan = self.make_plan(qty=0.3)
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
first = self.create_work_order(plan, field)
|
||||||
|
first.qty = 0.1
|
||||||
|
first.submit()
|
||||||
|
second = self.create_work_order(plan, field)
|
||||||
|
self.assertEqual(second.qty, 0.2)
|
||||||
|
second.submit()
|
||||||
|
self.assert_pending_qty(plan, field, 0)
|
||||||
|
|
||||||
|
def test_rows_with_same_item_are_independent(self):
|
||||||
|
plan = self.make_plan(submit=False)
|
||||||
|
sales_order = make_sales_order(item_code=self.finished_good, qty=200, warehouse=self.warehouse)
|
||||||
|
plan.po_items[0].sales_order = sales_order.name
|
||||||
|
plan.po_items[0].sales_order_item = sales_order.items[0].name
|
||||||
|
row = plan.po_items[0].as_dict()
|
||||||
|
row.pop("name")
|
||||||
|
row["planned_qty"] = 50
|
||||||
|
plan.append("po_items", row)
|
||||||
|
plan.submit()
|
||||||
|
first = self.create_work_order(plan, "production_plan_item")
|
||||||
|
first.submit()
|
||||||
|
plan.onload()
|
||||||
|
pending = plan.get_onload()["pending_work_order_qty"]["production_plan_item"]
|
||||||
|
self.assertEqual(pending[plan.po_items[0].name], 0)
|
||||||
|
self.assertEqual(pending[plan.po_items[1].name], 50)
|
||||||
|
second_name = frappe.db.get_value(
|
||||||
|
"Work Order", {"production_plan_item": plan.po_items[1].name, "docstatus": 0}, "name"
|
||||||
|
)
|
||||||
|
second = frappe.get_doc("Work Order", second_name)
|
||||||
|
self.assertEqual(second.qty, 50)
|
||||||
|
self.assert_overproduction(second, 51)
|
||||||
|
|
||||||
|
def test_material_request_plan_uses_remaining_quantity(self):
|
||||||
|
plan = self.make_plan(submit=False)
|
||||||
|
plan.get_items_from = "Material Request"
|
||||||
|
plan.submit()
|
||||||
|
first = self.create_work_order(plan, "production_plan_item")
|
||||||
|
first.qty = 50
|
||||||
|
first.submit()
|
||||||
|
second = self.create_work_order(plan, "production_plan_item")
|
||||||
|
self.assertEqual(second.qty, 50)
|
||||||
|
|
||||||
|
def test_reference_must_belong_to_plan(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
other_plan = self.make_plan()
|
||||||
|
work_order = self.create_work_order(plan, "production_plan_item")
|
||||||
|
work_order.production_plan = other_plan.name
|
||||||
|
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
|
||||||
|
work_order.submit()
|
||||||
|
|
||||||
|
def test_missing_or_ambiguous_plan_reference(self):
|
||||||
|
plan = self.make_plan()
|
||||||
|
work_order = self.create_work_order(plan, "production_plan_item")
|
||||||
|
work_order.production_plan_sub_assembly_item = plan.sub_assembly_items[0].name
|
||||||
|
with self.assertRaisesRegex(frappe.ValidationError, "only one Production Plan row"):
|
||||||
|
work_order.submit()
|
||||||
|
work_order.reload()
|
||||||
|
work_order.production_plan_item = None
|
||||||
|
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
|
||||||
|
work_order.submit()
|
||||||
|
|
||||||
|
def make_plan(self, qty=100, submit=True):
|
||||||
|
plan = create_production_plan(
|
||||||
|
item_code=self.finished_good,
|
||||||
|
planned_qty=qty,
|
||||||
|
stock_uom="Kg",
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
sub_assembly_warehouse=self.warehouse,
|
||||||
|
skip_getting_mr_items=True,
|
||||||
|
do_not_submit=True,
|
||||||
|
)
|
||||||
|
plan.get_sub_assembly_items()
|
||||||
|
if submit:
|
||||||
|
plan.submit()
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def make_bom_with_operation(self, item, material):
|
||||||
|
bom = make_bom(item=item, raw_materials=[material], with_operations=1, do_not_save=True)
|
||||||
|
bom.track_semi_finished_goods = 1
|
||||||
|
bom.items[0].operation_row_id = 1
|
||||||
|
operation = {
|
||||||
|
"operation": f"_Test Loss Reversal {item}",
|
||||||
|
"workstation": "_Test Workstation A",
|
||||||
|
"finished_good": item,
|
||||||
|
"finished_good_qty": 1,
|
||||||
|
"is_final_finished_good": 1,
|
||||||
|
"sequence_id": 1,
|
||||||
|
"time_in_mins": 60,
|
||||||
|
"source_warehouse": self.warehouse,
|
||||||
|
"fg_warehouse": self.warehouse,
|
||||||
|
"skip_material_transfer": 1,
|
||||||
|
}
|
||||||
|
make_workstation(operation)
|
||||||
|
make_operation(operation)
|
||||||
|
bom.append("operations", operation)
|
||||||
|
bom.insert().submit()
|
||||||
|
|
||||||
|
def create_work_order(self, plan, field):
|
||||||
|
plan.make_work_order()
|
||||||
|
name = frappe.db.get_value(
|
||||||
|
"Work Order",
|
||||||
|
{"production_plan": plan.name, field: self.plan_row(plan, field).name, "docstatus": 0},
|
||||||
|
"name",
|
||||||
|
order_by="creation desc",
|
||||||
|
)
|
||||||
|
work_order = frappe.get_doc("Work Order", name)
|
||||||
|
work_order.update(
|
||||||
|
{"skip_transfer": 1, "source_warehouse": self.warehouse, "fg_warehouse": self.warehouse}
|
||||||
|
)
|
||||||
|
return work_order
|
||||||
|
|
||||||
|
def copy_work_order(self, work_order):
|
||||||
|
copy = frappe.copy_doc(work_order)
|
||||||
|
copy.docstatus = 0
|
||||||
|
copy.production_plan = work_order.production_plan
|
||||||
|
for field in REFERENCE_FIELDS:
|
||||||
|
copy.set(field, work_order.get(field))
|
||||||
|
copy.insert()
|
||||||
|
return copy
|
||||||
|
|
||||||
|
def manufacture_with_loss(self, work_order, qty=None, *, loss_qty=None, submit=True):
|
||||||
|
for item in work_order.required_items:
|
||||||
|
make_stock_entry(
|
||||||
|
item_code=item.item_code, target=self.warehouse, qty=item.required_qty, basic_rate=10
|
||||||
|
)
|
||||||
|
entry = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", qty or work_order.qty))
|
||||||
|
if loss_qty is not None:
|
||||||
|
entry.process_loss_qty = loss_qty
|
||||||
|
entry.process_loss_percentage = loss_qty / entry.fg_completed_qty * 100
|
||||||
|
for item in entry.items:
|
||||||
|
if item.is_finished_item:
|
||||||
|
item.qty = entry.fg_completed_qty - loss_qty
|
||||||
|
if submit:
|
||||||
|
entry.submit()
|
||||||
|
else:
|
||||||
|
entry.save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def assert_manufacture_rejected(self, entry, exception):
|
||||||
|
frappe.db.savepoint("excess_manufacture")
|
||||||
|
try:
|
||||||
|
with self.assertRaises(exception):
|
||||||
|
entry.submit()
|
||||||
|
finally:
|
||||||
|
frappe.db.rollback(save_point="excess_manufacture")
|
||||||
|
self.assertEqual(entry.reload().docstatus, 0)
|
||||||
|
|
||||||
|
def assert_loss_reversal_blocked(self, document):
|
||||||
|
work_order = frappe.get_doc("Work Order", document.work_order)
|
||||||
|
frappe.db.savepoint("loss_reversal")
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
self.assert_plan_locked_before_work_order_update(work_order),
|
||||||
|
self.assertRaises(OverProductionError),
|
||||||
|
):
|
||||||
|
document.cancel()
|
||||||
|
finally:
|
||||||
|
# Match the request rollback after an on_cancel validation fails.
|
||||||
|
frappe.db.rollback(save_point="loss_reversal")
|
||||||
|
self.assertEqual(document.reload().docstatus, 1)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def assert_plan_locked_before_work_order_update(self, work_order):
|
||||||
|
get_value, set_value = frappe.db.get_value, frappe.db.set_value
|
||||||
|
plan_row_locked = False
|
||||||
|
row_doctype = (
|
||||||
|
"Production Plan Sub Assembly Item"
|
||||||
|
if work_order.production_plan_sub_assembly_item
|
||||||
|
else "Production Plan Item"
|
||||||
|
)
|
||||||
|
row_name = work_order.production_plan_sub_assembly_item or work_order.production_plan_item
|
||||||
|
|
||||||
|
def get_value_with_lock_check(doctype, filters=None, *args, **kwargs):
|
||||||
|
nonlocal plan_row_locked
|
||||||
|
if doctype == "Work Order" and filters == work_order.name and kwargs.get("for_update"):
|
||||||
|
self.assertTrue(plan_row_locked, "Work Order locked before its Production Plan row")
|
||||||
|
result = get_value(doctype, filters, *args, **kwargs)
|
||||||
|
if (
|
||||||
|
doctype == row_doctype
|
||||||
|
and filters == {"name": row_name, "parent": work_order.production_plan}
|
||||||
|
and kwargs.get("for_update")
|
||||||
|
):
|
||||||
|
plan_row_locked = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
def set_value_with_lock_check(doctype, name, *args, **kwargs):
|
||||||
|
if doctype == "Work Order" and name == work_order.name:
|
||||||
|
self.assertTrue(plan_row_locked, "Work Order updated before locking its Production Plan row")
|
||||||
|
return set_value(doctype, name, *args, **kwargs)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(frappe.db, "get_value", get_value_with_lock_check),
|
||||||
|
patch.object(frappe.db, "set_value", set_value_with_lock_check),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
def assert_overproduction(self, work_order, qty):
|
||||||
|
work_order.qty = qty
|
||||||
|
work_order.save()
|
||||||
|
with self.assertRaises(OverProductionError):
|
||||||
|
work_order.submit()
|
||||||
|
work_order.reload()
|
||||||
|
|
||||||
|
def assert_pending_qty(self, plan, field, expected):
|
||||||
|
plan.reload()
|
||||||
|
plan.onload()
|
||||||
|
self.assertEqual(
|
||||||
|
plan.get_onload()["pending_work_order_qty"][field][self.plan_row(plan, field).name], expected
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_row(self, plan, field):
|
||||||
|
return plan.po_items[0] if field == "production_plan_item" else plan.sub_assembly_items[0]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.utils import flt, get_link_to_form
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionPlanWorkOrderQuantities:
|
||||||
|
"""Count submitted Work Orders after recorded process loss, independently for each plan row."""
|
||||||
|
|
||||||
|
def __init__(self, production_plan):
|
||||||
|
self.production_plan = production_plan
|
||||||
|
|
||||||
|
def validate_work_order(self, work_order, *, process_loss_qty=0):
|
||||||
|
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
|
||||||
|
|
||||||
|
row = self.lock_plan_row(work_order)
|
||||||
|
|
||||||
|
committed = self.get_committed_quantities(
|
||||||
|
exclude_work_order=work_order.name,
|
||||||
|
reference_field=row.reference_field,
|
||||||
|
reference_name=row.name,
|
||||||
|
for_update=True,
|
||||||
|
)[row.reference_field].get(row.name, 0)
|
||||||
|
allowance = flt(
|
||||||
|
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||||
|
)
|
||||||
|
precision = work_order.precision("qty")
|
||||||
|
maximum_qty = flt(flt(row.planned_qty) * (1 + allowance / 100) - committed, precision)
|
||||||
|
committed_qty = flt(self._get_committed_qty(work_order, process_loss_qty), precision)
|
||||||
|
if committed_qty > maximum_qty:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Row {0} in {1} {2}: Work Order quantity after process loss {3} exceeds the remaining allowed quantity {4}."
|
||||||
|
).format(
|
||||||
|
row.idx,
|
||||||
|
_(row.doctype),
|
||||||
|
get_link_to_form("Production Plan", self.production_plan),
|
||||||
|
committed_qty,
|
||||||
|
max(0, maximum_qty),
|
||||||
|
),
|
||||||
|
OverProductionError,
|
||||||
|
title=_("Production Plan Quantity Exceeded"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def lock_plan_row(self, work_order):
|
||||||
|
if work_order.production_plan_item and work_order.production_plan_sub_assembly_item:
|
||||||
|
frappe.throw(_("Work Order must reference only one Production Plan row."))
|
||||||
|
|
||||||
|
if work_order.production_plan_sub_assembly_item:
|
||||||
|
reference_field = "production_plan_sub_assembly_item"
|
||||||
|
row_doctype, qty_field = "Production Plan Sub Assembly Item", "qty"
|
||||||
|
else:
|
||||||
|
reference_field = "production_plan_item"
|
||||||
|
row_doctype, qty_field = "Production Plan Item", "planned_qty"
|
||||||
|
|
||||||
|
reference_name = work_order.get(reference_field)
|
||||||
|
# Serialize submissions and loss reversals. The submit rollup updates this row.
|
||||||
|
row = (
|
||||||
|
frappe.db.get_value(
|
||||||
|
row_doctype,
|
||||||
|
{"name": reference_name, "parent": self.production_plan},
|
||||||
|
["name", "idx", f"{qty_field} as planned_qty"],
|
||||||
|
as_dict=True,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if reference_name
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
frappe.throw(
|
||||||
|
_("Work Order must reference a row in Production Plan {0}.").format(
|
||||||
|
get_link_to_form("Production Plan", self.production_plan)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
row.reference_field = reference_field
|
||||||
|
row.doctype = row_doctype
|
||||||
|
return row
|
||||||
|
|
||||||
|
def get_pending_quantities(self, plan):
|
||||||
|
committed = self.get_committed_quantities()
|
||||||
|
precision = frappe.get_precision("Work Order", "qty")
|
||||||
|
pending = {}
|
||||||
|
for table, reference_field, qty_field in (
|
||||||
|
("po_items", "production_plan_item", "planned_qty"),
|
||||||
|
("sub_assembly_items", "production_plan_sub_assembly_item", "qty"),
|
||||||
|
):
|
||||||
|
pending[reference_field] = {
|
||||||
|
row.name: max(
|
||||||
|
0, flt(flt(row.get(qty_field)) - committed[reference_field].get(row.name, 0), precision)
|
||||||
|
)
|
||||||
|
for row in plan.get(table)
|
||||||
|
if table == "po_items" or row.type_of_manufacturing == "In House"
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
|
||||||
|
def get_committed_quantities(
|
||||||
|
self, exclude_work_order=None, reference_field=None, reference_name=None, for_update=False
|
||||||
|
):
|
||||||
|
table = frappe.qb.DocType("Work Order")
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(table)
|
||||||
|
.select(
|
||||||
|
table.production_plan_item,
|
||||||
|
table.production_plan_sub_assembly_item,
|
||||||
|
table.qty,
|
||||||
|
table.produced_qty,
|
||||||
|
table.process_loss_qty,
|
||||||
|
)
|
||||||
|
.where((table.production_plan == self.production_plan) & (table.docstatus == 1))
|
||||||
|
.orderby(table.name)
|
||||||
|
)
|
||||||
|
if exclude_work_order:
|
||||||
|
query = query.where(table.name != exclude_work_order)
|
||||||
|
if reference_field:
|
||||||
|
query = query.where(table[reference_field] == reference_name)
|
||||||
|
# Use a current locking read so concurrent submissions see committed quantities.
|
||||||
|
if for_update:
|
||||||
|
query = query.for_update()
|
||||||
|
work_orders = query.run(as_dict=True)
|
||||||
|
quantities = {
|
||||||
|
"production_plan_item": defaultdict(float),
|
||||||
|
"production_plan_sub_assembly_item": defaultdict(float),
|
||||||
|
}
|
||||||
|
for work_order in work_orders:
|
||||||
|
field = (
|
||||||
|
"production_plan_sub_assembly_item"
|
||||||
|
if work_order.production_plan_sub_assembly_item
|
||||||
|
else "production_plan_item"
|
||||||
|
)
|
||||||
|
if work_order.get(field):
|
||||||
|
quantities[field][work_order[field]] += self._get_committed_qty(
|
||||||
|
work_order, work_order.process_loss_qty
|
||||||
|
)
|
||||||
|
return quantities
|
||||||
|
|
||||||
|
def _get_committed_qty(self, work_order, process_loss_qty):
|
||||||
|
# Excess loss in existing records must not erase finished goods already produced.
|
||||||
|
return max(0, flt(work_order.produced_qty), flt(work_order.qty) - flt(process_loss_qty))
|
||||||
@@ -34,6 +34,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
|||||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||||
get_mins_between_operations,
|
get_mins_between_operations,
|
||||||
)
|
)
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
|
||||||
|
ProductionPlanWorkOrderQuantities,
|
||||||
|
)
|
||||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||||
from erpnext.stock.doctype.batch.batch import make_batch
|
from erpnext.stock.doctype.batch.batch import make_batch
|
||||||
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
||||||
@@ -799,6 +802,8 @@ class WorkOrder(Document):
|
|||||||
if self.track_semi_finished_goods:
|
if self.track_semi_finished_goods:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Lock the plan row before any Work Order quantity update takes a row lock.
|
||||||
|
self.set_process_loss_qty()
|
||||||
allowance_percentage = flt(
|
allowance_percentage = flt(
|
||||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||||
)
|
)
|
||||||
@@ -825,16 +830,26 @@ class WorkOrder(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
completed_qty = self.qty + (allowance_percentage / 100 * self.qty)
|
completed_qty = self.qty + (allowance_percentage / 100 * self.qty)
|
||||||
if qty > completed_qty:
|
qty_to_validate = qty + flt(self.process_loss_qty) if purpose == "Manufacture" else qty
|
||||||
|
precision = self.precision(fieldname)
|
||||||
|
if flt(qty_to_validate, precision) > flt(completed_qty, precision):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
|
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
|
||||||
_(self.meta.get_label(fieldname)), qty, completed_qty, self.name
|
_("Manufactured Qty (including Process Loss)")
|
||||||
|
if purpose == "Manufacture"
|
||||||
|
else _(self.meta.get_label(fieldname)),
|
||||||
|
flt(qty_to_validate, precision),
|
||||||
|
completed_qty,
|
||||||
|
self.name,
|
||||||
),
|
),
|
||||||
StockOverProductionError,
|
StockOverProductionError,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db_set(fieldname, qty)
|
self.db_set(fieldname, qty)
|
||||||
self.set_process_loss_qty()
|
if purpose == "Manufacture" and self.production_plan:
|
||||||
|
ProductionPlanWorkOrderQuantities(self.production_plan).validate_work_order(
|
||||||
|
self, process_loss_qty=self.process_loss_qty
|
||||||
|
)
|
||||||
|
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
||||||
|
|
||||||
@@ -905,7 +920,20 @@ class WorkOrder(Document):
|
|||||||
return flt(query.run()[0][0])
|
return flt(query.run()[0][0])
|
||||||
|
|
||||||
def set_process_loss_qty(self):
|
def set_process_loss_qty(self):
|
||||||
self.db_set("process_loss_qty", self._process_loss_qty())
|
quantities = None
|
||||||
|
if self.docstatus == 1 and self.production_plan:
|
||||||
|
quantities = ProductionPlanWorkOrderQuantities(self.production_plan)
|
||||||
|
quantities.lock_plan_row(self)
|
||||||
|
|
||||||
|
process_loss_qty = self._process_loss_qty()
|
||||||
|
if quantities:
|
||||||
|
previous_loss_qty = frappe.db.get_value(
|
||||||
|
"Work Order", self.name, "process_loss_qty", for_update=True
|
||||||
|
)
|
||||||
|
if process_loss_qty < flt(previous_loss_qty):
|
||||||
|
# Replacement Work Orders may have consumed the recorded loss.
|
||||||
|
quantities.validate_work_order(self, process_loss_qty=process_loss_qty)
|
||||||
|
self.db_set("process_loss_qty", process_loss_qty)
|
||||||
|
|
||||||
def _process_loss_qty(self):
|
def _process_loss_qty(self):
|
||||||
if self.track_semi_finished_goods:
|
if self.track_semi_finished_goods:
|
||||||
@@ -950,6 +978,8 @@ class WorkOrder(Document):
|
|||||||
frappe.throw(_("Target Warehouse is required before Submit"))
|
frappe.throw(_("Target Warehouse is required before Submit"))
|
||||||
|
|
||||||
def before_submit(self):
|
def before_submit(self):
|
||||||
|
if self.production_plan:
|
||||||
|
ProductionPlanWorkOrderQuantities(self.production_plan).validate_work_order(self)
|
||||||
self.create_serial_no_batch_no()
|
self.create_serial_no_batch_no()
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
@@ -1622,36 +1652,6 @@ class WorkOrder(Document):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.production_plan and self.production_plan_item and not self.production_plan_sub_assembly_item:
|
|
||||||
qty_dict = frappe.db.get_value(
|
|
||||||
"Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1
|
|
||||||
)
|
|
||||||
|
|
||||||
if not qty_dict:
|
|
||||||
return
|
|
||||||
|
|
||||||
allowance_qty = (
|
|
||||||
flt(
|
|
||||||
frappe.db.get_single_value(
|
|
||||||
"Manufacturing Settings", "overproduction_percentage_for_work_order"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
/ 100
|
|
||||||
* qty_dict.get("planned_qty", 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
max_qty = qty_dict.get("planned_qty", 0) + allowance_qty - qty_dict.get("ordered_qty", 0)
|
|
||||||
|
|
||||||
if max_qty <= 0:
|
|
||||||
frappe.throw(
|
|
||||||
_("Cannot produce more item for {0}").format(self.production_item), OverProductionError
|
|
||||||
)
|
|
||||||
elif self.qty > max_qty:
|
|
||||||
frappe.throw(
|
|
||||||
_("Cannot produce more than {0} items for {1}").format(max_qty, self.production_item),
|
|
||||||
OverProductionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.subcontracting_inward_order and self.qty > self.max_producible_qty:
|
if self.subcontracting_inward_order and self.qty > self.max_producible_qty:
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
_(
|
_(
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
|||||||
get_secondary_items_from_sub_assemblies,
|
get_secondary_items_from_sub_assemblies,
|
||||||
validate_bom_no,
|
validate_bom_no,
|
||||||
)
|
)
|
||||||
|
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
|
||||||
|
ProductionPlanWorkOrderQuantities,
|
||||||
|
)
|
||||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||||
@@ -2517,6 +2520,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
if self.work_order:
|
if self.work_order:
|
||||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||||
_validate_work_order(pro_doc)
|
_validate_work_order(pro_doc)
|
||||||
|
if pro_doc.production_plan:
|
||||||
|
ProductionPlanWorkOrderQuantities(pro_doc.production_plan).lock_plan_row(pro_doc)
|
||||||
|
|
||||||
if self.fg_completed_qty:
|
if self.fg_completed_qty:
|
||||||
if self.docstatus == 1:
|
if self.docstatus == 1:
|
||||||
|
|||||||
Reference in New Issue
Block a user