mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-08 23:09:33 +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 (
|
||||
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.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
|
||||
get_subcontracting_boms_for_finished_goods,
|
||||
@@ -990,6 +993,10 @@ class JobCard(Document):
|
||||
if not self.operation_id:
|
||||
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 Card",
|
||||
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)
|
||||
|
||||
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
|
||||
if (
|
||||
self.finished_good
|
||||
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
|
||||
== self.finished_good
|
||||
):
|
||||
_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))
|
||||
_wo_doc.db_set("status", _wo_doc.get_status())
|
||||
if self.finished_good and work_order.production_item == self.finished_good:
|
||||
work_order.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
|
||||
if work_order.production_plan:
|
||||
ProductionPlanWorkOrderQuantities(work_order.production_plan).validate_work_order(
|
||||
work_order, process_loss_qty=work_order.process_loss_qty
|
||||
)
|
||||
work_order.db_set("status", work_order.get_status())
|
||||
|
||||
def update_corrective_in_work_order(self, wo):
|
||||
wo.corrective_operation_cost = 0.0
|
||||
|
||||
@@ -230,6 +230,14 @@ frappe.ui.form.on("Production Plan", {
|
||||
|
||||
let has_items =
|
||||
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) {
|
||||
return item.planned_qty > item.ordered_qty;
|
||||
} 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 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.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
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"),
|
||||
)
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.set_onload(
|
||||
"pending_work_order_qty",
|
||||
ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self),
|
||||
)
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
@@ -728,6 +737,7 @@ class ProductionPlan(Document):
|
||||
|
||||
def get_production_items(self):
|
||||
item_dict = {}
|
||||
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
|
||||
|
||||
for d in self.po_items:
|
||||
item_details = {
|
||||
@@ -750,29 +760,12 @@ class ProductionPlan(Document):
|
||||
"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:
|
||||
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
|
||||
|
||||
if self.get_items_from == "Material Request":
|
||||
item_details.update({"qty": d.planned_qty})
|
||||
item_dict[
|
||||
(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
|
||||
item_details["qty"] = pending["production_plan_item"][d.name]
|
||||
# A Work Order can reference only one Production Plan row.
|
||||
item_dict[d.name] = item_details
|
||||
|
||||
return item_dict
|
||||
|
||||
@@ -780,6 +773,7 @@ class ProductionPlan(Document):
|
||||
def make_work_order(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
|
||||
|
||||
self.reload()
|
||||
wo_list, po_list = [], []
|
||||
subcontracted_po = {}
|
||||
default_warehouses = get_default_warehouse(self.company)
|
||||
@@ -809,6 +803,7 @@ class ProductionPlan(Document):
|
||||
wo_list.append(work_order)
|
||||
|
||||
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:
|
||||
if row.type_of_manufacturing == "Subcontract":
|
||||
subcontracted_po.setdefault(row.supplier, []).append(row)
|
||||
@@ -825,10 +820,9 @@ class ProductionPlan(Document):
|
||||
"company": self.get("company"),
|
||||
}
|
||||
|
||||
if flt(row.qty) <= flt(row.ordered_qty):
|
||||
continue
|
||||
|
||||
self.prepare_data_for_sub_assembly_items(row, work_order_data)
|
||||
self.prepare_data_for_sub_assembly_items(
|
||||
row, work_order_data, pending["production_plan_sub_assembly_item"][row.name]
|
||||
)
|
||||
|
||||
if work_order_data.get("qty") <= 0:
|
||||
continue
|
||||
@@ -837,7 +831,7 @@ class ProductionPlan(Document):
|
||||
if 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 [
|
||||
"production_item",
|
||||
"item_name",
|
||||
@@ -853,7 +847,11 @@ class ProductionPlan(Document):
|
||||
if 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(
|
||||
{
|
||||
|
||||
@@ -1567,12 +1567,12 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
def test_multiple_work_order_for_production_plan_item(self):
|
||||
"Test producing Prod Plan (making WO) in parts."
|
||||
|
||||
def create_work_order(item, pln, qty):
|
||||
def create_work_order(pln, qty):
|
||||
# Get Production Items
|
||||
items_data = pln.get_production_items()
|
||||
|
||||
# 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
|
||||
for _key, item in items_data.items():
|
||||
@@ -1600,17 +1600,17 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
wo_list = []
|
||||
|
||||
# Create and Submit 1st Work Order for 3 qty
|
||||
create_work_order(item, pln, 3)
|
||||
create_work_order(pln, 3)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 3)
|
||||
|
||||
# Create and Submit 2nd Work Order for 2 qty
|
||||
create_work_order(item, pln, 2)
|
||||
create_work_order(pln, 2)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 5)
|
||||
|
||||
# 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
|
||||
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)
|
||||
|
||||
# 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.submit()
|
||||
|
||||
# Step - 2: Create Work Orders
|
||||
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 (
|
||||
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.stock.doctype.batch.batch import make_batch
|
||||
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:
|
||||
return
|
||||
|
||||
# Lock the plan row before any Work Order quantity update takes a row lock.
|
||||
self.set_process_loss_qty()
|
||||
allowance_percentage = flt(
|
||||
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)
|
||||
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(
|
||||
_("{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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -905,7 +920,20 @@ class WorkOrder(Document):
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
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):
|
||||
if self.track_semi_finished_goods:
|
||||
@@ -950,6 +978,8 @@ class WorkOrder(Document):
|
||||
frappe.throw(_("Target Warehouse is required before Submit"))
|
||||
|
||||
def before_submit(self):
|
||||
if self.production_plan:
|
||||
ProductionPlanWorkOrderQuantities(self.production_plan).validate_work_order(self)
|
||||
self.create_serial_no_batch_no()
|
||||
|
||||
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:
|
||||
frappe.msgprint(
|
||||
_(
|
||||
|
||||
@@ -33,6 +33,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
get_secondary_items_from_sub_assemblies,
|
||||
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.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
@@ -2517,6 +2520,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
if self.work_order:
|
||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
_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.docstatus == 1:
|
||||
|
||||
Reference in New Issue
Block a user