diff --git a/makap_backend/makap_backend/doctype/print_cylinder/test_print_cylinder.py b/makap_backend/makap_backend/doctype/print_cylinder/test_print_cylinder.py new file mode 100644 index 0000000..909b54c --- /dev/null +++ b/makap_backend/makap_backend/doctype/print_cylinder/test_print_cylinder.py @@ -0,0 +1,110 @@ +import frappe +from frappe.tests.utils import FrappeTestCase + +from makap_backend.makap_backend import operations, work_order_readiness + +ITEM = "_Makap Test Printed Pouch" +CUSTOMER = "_Makap Test Customer" + + +def _ensure_masters(): + if not frappe.db.exists("Item", ITEM): + frappe.get_doc( + {"doctype": "Item", "item_code": ITEM, "item_group": "All Item Groups", "stock_uom": "Nos"} + ).insert() + if not frappe.db.exists("Customer", CUSTOMER): + frappe.get_doc( + { + "doctype": "Customer", + "customer_name": CUSTOMER, + "customer_group": "All Customer Groups", + "territory": "All Territories", + } + ).insert() + + +def _approved_spec(print_tooling_required=1): + return frappe.get_doc( + { + "doctype": "Packaging Specification", + "specification_series": "TEST-POUCH", + "revision": "A", + "customer": CUSTOMER, + "item": ITEM, + "product_family": "Pouch", + "specification_status": "Approved for Manufacture", + "quote_eligible": 1, + "quote_eligible_by": "Administrator", + "quote_eligible_on": frappe.utils.now_datetime(), + "approved_by": "Administrator", + "approved_on": frappe.utils.now_datetime(), + "print_tooling_required": print_tooling_required, + } + ).insert() + + +def _cylinder(spec, cylinder_id, status="Ready", condition="Good"): + return frappe.get_doc( + { + "doctype": "Print Cylinder", + "cylinder_id": cylinder_id, + "tooling_type": "Gravure Cylinder", + "status": status, + "condition": condition, + "ownership": "Makap", + "packaging_specification": spec.name, + } + ).insert() + + +class TestPrintCylinder(FrappeTestCase): + def setUp(self): + _ensure_masters() + + def test_damaged_cylinder_cannot_be_ready(self): + spec = _approved_spec() + self.assertRaises(frappe.ValidationError, _cylinder, spec, "CYL-T-DMG", "Ready", "Damaged") + + def test_tooling_blocks_until_every_cylinder_is_ready(self): + spec = _approved_spec() + self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Blocked") + + _cylinder(spec, "CYL-T-1", "Ready") + second = _cylinder(spec, "CYL-T-2", "In Prepress") + self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Blocked") + + second.status = "Ready" + second.save() + self.assertEqual(work_order_readiness.tooling_status(spec.name), ("Ready", [])) + + def test_retired_cylinders_are_ignored(self): + spec = _approved_spec() + _cylinder(spec, "CYL-T-R1", "Ready") + retired = _cylinder(spec, "CYL-T-R2", "Ordered") + retired.status = "Retired" + retired.retirement_reason = "Replaced" + retired.save() + self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Ready") + + def test_no_tooling_needed(self): + spec = _approved_spec(print_tooling_required=0) + self.assertEqual(work_order_readiness.tooling_status(spec.name), ("Not Required", [])) + + +class TestOperationsNeedRoles(FrappeTestCase): + USER = "makap-test-operator@example.com" + + def setUp(self): + if not frappe.db.exists("User", self.USER): + user = frappe.get_doc({"doctype": "User", "email": self.USER, "first_name": "Operator"}).insert() + user.add_roles("Manufacturing User") + frappe.set_user(self.USER) + + def tearDown(self): + frappe.set_user("Administrator") + + def test_operator_cannot_set_quality_disposition(self): + self.assertRaises(frappe.PermissionError, operations.apply_quality_disposition, "ANY-QI", "Hold") + + def test_operator_cannot_mark_work_order_ready(self): + self.assertRaises(frappe.PermissionError, operations.evaluate_work_order_readiness, "ANY-WO") diff --git a/makap_backend/makap_backend/test_gates.py b/makap_backend/makap_backend/test_gates.py new file mode 100644 index 0000000..30eef17 --- /dev/null +++ b/makap_backend/makap_backend/test_gates.py @@ -0,0 +1,124 @@ +"""Gate logic that needs no fixtures: database reads are patched so each rule +is tested on its own. Run with `bench --site run-tests --app makap_backend`. +""" + +from unittest.mock import patch + +import frappe +from frappe.tests.utils import FrappeTestCase + +from makap_backend.makap_backend import controls, work_order_readiness + +MODULE = "makap_backend.makap_backend" + + +def _doc(**values): + return frappe._dict(values) + + +class TestDispatchGate(FrappeTestCase): + def _gate(self, line, reservation_enabled=0, disposition=None): + doc = _doc( + customer="C-1", + items=[_doc(item_code="ROLL", qty=10, makap_stock_reservation_entry=None, **line)], + makap_dispatch_material_accountability_status="Cleared", + makap_financial_release_status="Cleared", + makap_short_close=0, + ) + with ( + patch(f"{MODULE}.controls._is_controlled_item", return_value=True), + patch(f"{MODULE}.controls._latest_quality_disposition", return_value=disposition), + patch.object(frappe.db, "get_single_value", return_value=reservation_enabled), + ): + return controls._compute_dispatch_gate(doc) + + def test_controlled_line_without_batch_is_blocked(self): + status, blockers = self._gate({"makap_dispatch_allocation_mode": "Pooled"}) + self.assertEqual(status, "Blocked") + self.assertTrue(any("no batch selected" in b for b in blockers)) + + def test_batch_needs_its_own_released_final_inspection(self): + status, blockers = self._gate({"makap_dispatch_allocation_mode": "Pooled", "batch_no": "R-1"}) + self.assertEqual(status, "Blocked") + self.assertTrue(any("Batch R-1 has no final Quality Inspection" in b for b in blockers)) + + def test_released_batch_passes(self): + released = _doc(makap_quality_eligibility="Eligible", makap_quality_disposition="Release") + status, blockers = self._gate( + {"makap_dispatch_allocation_mode": "Pooled", "batch_no": "R-1"}, disposition=released + ) + self.assertEqual((status, blockers), ("Ready", [])) + + def test_reserved_mode_needs_stock_reservation_enabled(self): + released = _doc(makap_quality_eligibility="Eligible", makap_quality_disposition="Release") + _, blockers = self._gate( + {"makap_dispatch_allocation_mode": "Reserved", "batch_no": "R-1"}, disposition=released + ) + self.assertTrue(any("Stock Reservation enabled" in b for b in blockers)) + + +class TestJobCard(FrappeTestCase): + def test_waste_needs_a_reason(self): + card = _doc(makap_setup_waste_kg=5, makap_running_waste_kg=0, makap_output_weight_kg=95) + self.assertRaises(frappe.ValidationError, controls.validate_job_card, card) + + def test_waste_pct_is_share_of_input(self): + card = _doc( + makap_setup_waste_kg=3, makap_running_waste_kg=2, makap_output_weight_kg=95, + makap_waste_reason="Registration", + ) + controls.validate_job_card(card) + self.assertAlmostEqual(card.makap_waste_pct, 5.0) + + def test_negative_weights_rejected(self): + card = _doc(makap_setup_waste_kg=-1, makap_waste_reason="Other") + self.assertRaises(frappe.ValidationError, controls.validate_job_card, card) + + +class TestQuotationEstimate(FrappeTestCase): + BOM = _doc(item="BAG", quantity=1000, raw_material_cost=500000, operating_cost=200000, docstatus=1) + + def _line(self, **values): + line = _doc(item_code="BAG", stock_qty=10000, base_net_amount=9000000, makap_estimate_bom="BOM-BAG") + line.update(values) + line.set = lambda field, value: line.__setitem__(field, value) + return line + + def test_cost_build_up(self): + line = self._line(makap_waste_pct=10, makap_setup_cost=150000, makap_cylinder_charge=850000) + with patch.object(frappe.db, "get_value", return_value=self.BOM): + controls._estimate_line_cost(line) + # material 500 x 10,000 x 1.10 = 5,500,000; conversion 200 x 10,000 = 2,000,000 + self.assertAlmostEqual(line.makap_material_cost, 5500000) + self.assertAlmostEqual(line.makap_conversion_cost, 2000000) + self.assertAlmostEqual(line.makap_estimated_cost, 8500000) + self.assertAlmostEqual(line.makap_estimated_margin_pct, 500000 / 9000000 * 100) + + def test_bom_must_be_for_the_quoted_item(self): + line = self._line(item_code="OTHER") + with patch.object(frappe.db, "get_value", return_value=self.BOM): + self.assertRaises(frappe.ValidationError, controls._estimate_line_cost, line) + + +class TestSpecificationReachesWorkOrder(FrappeTestCase): + def test_sub_assembly_order_finds_spec_through_production_plan(self): + values = { + ("Production Plan Sub Assembly Item", "PPSA-1", "production_plan_item"): "PPI-1", + ("Production Plan Item", "PPI-1", "sales_order_item"): "SOI-1", + ("Sales Order Item", "SOI-1", "makap_packaging_specification"): "PS-1", + ("Packaging Specification", "PS-1", "revision"): "B", + ("Packaging Specification", "PS-1", "customer"): "C-1", + } + work_order = _doc(production_plan_sub_assembly_item="PPSA-1", makap_packaging_specification=None) + with patch.object(frappe.db, "get_value", side_effect=lambda dt, name, field: values.get((dt, name, field))): + work_order_readiness.apply_specification(work_order) + self.assertEqual(work_order.makap_packaging_specification, "PS-1") + self.assertEqual(work_order.makap_specification_revision, "B") + self.assertEqual(work_order.makap_sales_order_item_reference, "SOI-1") + + def test_release_blocked_without_bom_or_confirmations(self): + work_order = _doc(production_item=None, bom_no=None) + blockers = work_order_readiness.release_blockers(work_order) + self.assertIn("No BOM is selected", blockers) + self.assertIn("Process compatibility is not confirmed", blockers) + self.assertEqual(work_order.makap_tooling_readiness_status, "Not Required")