diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index 8bec6d8df3a..3b0eef171fe 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -21,6 +21,10 @@ from .serial_batch import create_serial_and_batch_bundle from .stock_entry_base import BaseStockEntry +class DuplicateEntryForWorkOrderError(frappe.ValidationError): + pass + + class OperationsNotCompleteError(frappe.ValidationError): pass @@ -279,6 +283,7 @@ class ManufactureStockEntry(BaseManufactureStockEntry): self.validate_warehouse() self.validate_raw_materials_exists() self.check_if_operations_completed() + self.check_duplicate_entry_for_work_order() self.validate_component_and_quantities() self.validate_finished_good_serial_batch_for_work_order() @@ -430,6 +435,50 @@ class ManufactureStockEntry(BaseManufactureStockEntry): OperationsNotCompleteError, ) + def check_duplicate_entry_for_work_order(self): + """Block another manufacture entry once existing entries already cover the work order qty plus allowance.""" + if not self.wo_doc or self.wo_doc.track_semi_finished_goods: + return + + other_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": self.doc.work_order, + "purpose": self.doc.purpose, + "docstatus": ["!=", 2], + "name": ["!=", self.doc.name], + }, + pluck="name", + ) + if not other_entries: + return + + allowance_percentage = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") + ) + allowed_qty = flt(self.wo_doc.qty) + (allowance_percentage / 100 * flt(self.wo_doc.qty)) + if self.get_fg_qty_already_entered(other_entries) >= allowed_qty: + frappe.throw( + _("Stock Entries already created for Work Order {0}: {1}").format( + self.doc.work_order, ", ".join(other_entries) + ), + DuplicateEntryForWorkOrderError, + ) + + def get_fg_qty_already_entered(self, other_entries): + child = frappe.qb.DocType("Stock Entry Detail") + qty = ( + frappe.qb.from_(child) + .select(Sum(child.transfer_qty)) + .where( + child.parent.isin(other_entries) + & (child.item_code == self.wo_doc.production_item) + & (child.s_warehouse.isnull() | (child.s_warehouse == "")) + ) + .run() + )[0][0] + return flt(qty) + def add_items(self): self.add_raw_materials() self.set_process_loss_qty() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 7ce519bf89d..13ae8e66aae 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -38,6 +38,7 @@ from erpnext.stock.utils import get_incoming_rate from .services.disassemble import DisassembleStockEntry from .services.manufacturing import ( + DuplicateEntryForWorkOrderError, ManufactureStockEntry, MaterialConsumptionForManufactureStockEntry, OperationsNotCompleteError, diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 5a29b5c37cb..56d2a6c0efb 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -28,6 +28,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle ) from erpnext.stock.doctype.serial_no.serial_no import * from erpnext.stock.doctype.stock_entry.stock_entry import ( + DuplicateEntryForWorkOrderError, FinishedGoodError, get_pending_work_orders, make_stock_in_entry, @@ -1272,6 +1273,36 @@ class TestStockEntry(ERPNextTestSuite): se_ok.submit() self.assertEqual(se_ok.docstatus, 1) + def test_duplicate_entry_for_work_order(self): + from erpnext.manufacturing.doctype.work_order.mapper import ( + make_stock_entry as make_wo_stock_entry, + ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + + wo = make_wo_order_test_record(qty=1) + make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100) + make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100 + ) + + transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1)) + for d in transfer.get("items"): + d.s_warehouse = "Stores - _TC" + transfer.insert() + transfer.submit() + + mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1)) + mfg.insert() + + duplicate = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1)) + self.assertRaises(DuplicateEntryForWorkOrderError, duplicate.insert) + + with self.change_settings( + "Manufacturing Settings", {"overproduction_percentage_for_work_order": 100} + ): + within_allowance = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1)) + within_allowance.insert() + @ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"}) def test_quality_inspection_required_for_manufacture(self): from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError