diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index eead67fb458..513974c0adb 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -34,6 +34,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_entry import test_stock_entry +from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_bin from erpnext.tests.utils import ERPNextTestSuite @@ -509,6 +510,18 @@ class TestWorkOrder(ERPNextTestSuite): for stock_entry in stock_entries: stock_entry.cancel() + @timeout(seconds=60) + def test_manufacture_blocked_until_operations_completed(self): + bom = frappe.get_doc( + "BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0} + ) + work_order = make_wo_order_test_record( + item=bom.item, qty=1, bom_no=bom.name, source_warehouse="_Test Warehouse - _TC", skip_transfer=1 + ) + + stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) + self.assertRaises(OperationsNotCompleteError, stock_entry.insert) + def test_work_order_material_transferred_qty_with_process_loss(self): stock_entries = [] item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index 672a6ebd12a..8bec6d8df3a 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 OperationsNotCompleteError(frappe.ValidationError): + pass + + class BaseManufactureStockEntry(BaseStockEntry): def set_default_warehouse(self): for row in self.doc.items: @@ -274,6 +278,7 @@ class ManufactureStockEntry(BaseManufactureStockEntry): def validate(self): self.validate_warehouse() self.validate_raw_materials_exists() + self.check_if_operations_completed() self.validate_component_and_quantities() self.validate_finished_good_serial_batch_for_work_order() @@ -381,6 +386,50 @@ class ManufactureStockEntry(BaseManufactureStockEntry): if not self.doc.work_order: frappe.throw(_("Work Order is mandatory")) + def check_if_operations_completed(self): + """Require operation (job card) completion before manufacture, so operating costs are captured.""" + if not self.wo_doc or self.wo_doc.track_semi_finished_goods: + return + + allowance_percentage = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") + ) + total_completed_qty = flt(self.doc.fg_completed_qty) + flt(self.wo_doc.produced_qty) + precision = self.doc.precision("fg_completed_qty") + + for row in self.wo_doc.operations: + allowed_qty = ( + row.completed_qty + row.process_loss_qty + (allowance_percentage / 100 * row.completed_qty) + ) + if flt(total_completed_qty, precision) > flt(allowed_qty, precision): + self.throw_operations_not_complete_error(row, total_completed_qty) + + def throw_operations_not_complete_error(self, operation_row, total_completed_qty): + job_card = frappe.db.get_value( + "Job Card", + {"operation_id": operation_row.name, "docstatus": ("<", 2), "is_corrective_job_card": 0}, + "name", + ) + if not job_card: + frappe.throw( + _("Work Order {0}: Job Card not found for the operation {1}").format( + self.doc.work_order, operation_row.operation + ) + ) + + frappe.throw( + _( + "Row #{0}: Operation {1} is not completed for {2} qty of finished goods in Work Order {3}. Please update operation status via Job Card {4}." + ).format( + operation_row.idx, + bold(operation_row.operation), + bold(total_completed_qty), + get_link_to_form("Work Order", self.doc.work_order), + get_link_to_form("Job Card", job_card), + ), + OperationsNotCompleteError, + ) + def add_items(self): self.add_raw_materials() self.set_process_loss_qty() @@ -852,6 +901,7 @@ class MaterialConsumptionForManufactureStockEntry(ManufactureStockEntry): def validate(self): self.validate_work_order() + self.check_if_operations_completed() def add_items(self): if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8d798e2c1c6..7ce519bf89d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -40,6 +40,7 @@ from .services.disassemble import DisassembleStockEntry from .services.manufacturing import ( ManufactureStockEntry, MaterialConsumptionForManufactureStockEntry, + OperationsNotCompleteError, RepackStockEntry, ) from .services.material_receipt_issue import MaterialIssueStockEntry, MaterialReceiptStockEntry