Close review gaps: gate fixes, cylinder register, roll tracking, costing, Uganda setup #1
@@ -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")
|
||||||
@@ -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 <test-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")
|
||||||
Reference in New Issue
Block a user