diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 09a96c4a..b5ccd317 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.8.3.1', + 'version': '19.0.8.4.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 54bc58cc..600187ab 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -17,6 +17,7 @@ from . import fp_bath_replenishment_rule from . import fp_process_node from . import fp_rack from . import fp_job +from . import fp_job_step from . import fp_operator_certification from . import fp_tz from . import res_company diff --git a/fusion_plating/fusion_plating/models/fp_job.py b/fusion_plating/fusion_plating/models/fp_job.py index 5c917f45..511aca90 100644 --- a/fusion_plating/fusion_plating/models/fp_job.py +++ b/fusion_plating/fusion_plating/models/fp_job.py @@ -178,6 +178,45 @@ class FpJob(models.Model): else: job.current_location = job.state.replace('_', ' ').title() + # ------------------------------------------------------------------ + # Steps — One2many to fp.job.step (Task 1.5) + # ------------------------------------------------------------------ + step_ids = fields.One2many( + 'fp.job.step', + 'job_id', + string='Steps', + ) + step_count = fields.Integer(compute='_compute_step_counts') + step_done_count = fields.Integer(compute='_compute_step_counts') + step_progress_pct = fields.Float(compute='_compute_step_counts') + current_step_id = fields.Many2one( + 'fp.job.step', + compute='_compute_current_step', + ) + + @api.depends('step_ids', 'step_ids.state') + def _compute_step_counts(self): + for job in self: + job.step_count = len(job.step_ids) + job.step_done_count = len(job.step_ids.filtered(lambda s: s.state == 'done')) + job.step_progress_pct = ( + (job.step_done_count / job.step_count * 100.0) + if job.step_count else 0.0 + ) + + @api.depends('step_ids.state', 'step_ids.sequence') + def _compute_current_step(self): + for job in self: + in_prog = job.step_ids.filtered(lambda s: s.state == 'in_progress') + if in_prog: + job.current_step_id = in_prog.sorted('sequence')[:1] + continue + ready = job.step_ids.filtered(lambda s: s.state == 'ready') + if ready: + job.current_step_id = ready.sorted('sequence')[:1] + continue + job.current_step_id = False + @api.model_create_multi def create(self, vals_list): for vals in vals_list: diff --git a/fusion_plating/fusion_plating/models/fp_job_step.py b/fusion_plating/fusion_plating/models/fp_job_step.py new file mode 100644 index 00000000..363b3d0d --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_job_step.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# fp.job.step — one operation within a plating job. +# +# Replaces mrp.workorder. Each step instantiates from a recipe +# operation node (recipe_node_id). Container nodes (recipe, +# sub_process) and step nodes (instructions) are NOT rows here — +# they live on the recipe template and are used at view-render time +# to display hierarchy. See spec §5.2 (Option A — operations only). +# +# State machine: +# pending → ready → in_progress → done +# ↓ ↓ ↑ +# skipped paused +# ↓ +# cancelled + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FpJobStep(models.Model): + _name = 'fp.job.step' + _description = 'Plating Job Step' + _inherit = ['mail.thread'] + _order = 'job_id, sequence, id' + + job_id = fields.Many2one( + 'fp.job', + required=True, + ondelete='cascade', + index=True, + ) + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + state = fields.Selection( + [ + ('pending', 'Pending'), + ('ready', 'Ready'), + ('in_progress', 'In Progress'), + ('paused', 'Paused'), + ('done', 'Done'), + ('skipped', 'Skipped'), + ('cancelled', 'Cancelled'), + ], + default='pending', + required=True, + tracking=True, + index=True, + ) + recipe_node_id = fields.Many2one( + 'fusion.plating.process.node', + string='Recipe Operation', + domain=[('node_type', '=', 'operation')], + ) + work_centre_id = fields.Many2one('fp.work.centre', index=True) + kind = fields.Selection( + [ + ('wet', 'Wet'), + ('bake', 'Bake'), + ('mask', 'Mask'), + ('rack', 'Rack'), + ('inspect', 'Inspect'), + ('other', 'Other'), + ], + default='other', + ) + assigned_user_id = fields.Many2one('res.users', tracking=True) + started_by_user_id = fields.Many2one('res.users', readonly=True) + finished_by_user_id = fields.Many2one('res.users', readonly=True) + date_started = fields.Datetime(readonly=True) + date_finished = fields.Datetime(readonly=True) + duration_expected = fields.Float(string='Expected Minutes') + duration_actual = fields.Float(string='Actual Minutes', readonly=True) + instructions = fields.Html(string='Step Instructions') + + def button_start(self): + for step in self: + if step.state not in ('ready', 'paused'): + raise UserError(_( + "Step '%s' is in state '%s' — only ready/paused steps can start." + ) % (step.name, step.state)) + step.state = 'in_progress' + if not step.date_started: + step.date_started = fields.Datetime.now() + step.started_by_user_id = self.env.user + return True + + def button_finish(self): + for step in self: + if step.state != 'in_progress': + raise UserError(_( + "Step '%s' is in state '%s' — only in-progress steps can finish." + ) % (step.name, step.state)) + step.state = 'done' + step.date_finished = fields.Datetime.now() + step.finished_by_user_id = self.env.user + return True diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 4adae4dc..ed9d9e44 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -50,3 +50,6 @@ access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0 access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0 +access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index b2a7f2e0..60b0d988 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import test_fp_work_centre from . import test_fp_job_state_machine +from . import test_fp_job_step_state_machine diff --git a/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py b/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py new file mode 100644 index 00000000..60ec2ac6 --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class TestFpJobStepStateMachine(TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Cust'}) + self.product = self.env['product.product'].create({'name': 'Widget'}) + self.wc = self.env['fp.work.centre'].create({ + 'name': 'WC', 'code': 'WC', 'kind': 'wet_line', + }) + self.job = self.env['fp.job'].create({ + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1.0, + }) + + def _make_step(self, **kw): + vals = { + 'job_id': self.job.id, + 'name': 'Plating Bath', + 'sequence': 10, + 'work_centre_id': self.wc.id, + } + vals.update(kw) + return self.env['fp.job.step'].create(vals) + + def test_step_starts_pending(self): + step = self._make_step() + self.assertEqual(step.state, 'pending') + + def test_button_start_requires_ready_or_paused(self): + step = self._make_step() + # state is 'pending' — should raise + with self.assertRaises(UserError): + step.button_start() + + def test_button_start_moves_ready_to_in_progress(self): + step = self._make_step() + step.state = 'ready' + step.button_start() + self.assertEqual(step.state, 'in_progress') + self.assertTrue(step.date_started) + self.assertEqual(step.started_by_user_id, self.env.user) + + def test_button_finish_requires_in_progress(self): + step = self._make_step() + with self.assertRaises(UserError): + step.button_finish() # state is pending + + def test_button_finish_moves_to_done(self): + step = self._make_step() + step.state = 'ready' + step.button_start() + step.button_finish() + self.assertEqual(step.state, 'done') + self.assertTrue(step.date_finished) + self.assertEqual(step.finished_by_user_id, self.env.user) + + def test_job_step_counts_update(self): + # Add 3 steps; finish 1; verify computed counts on job header. + s1 = self._make_step(name='Step 1', sequence=10) + s2 = self._make_step(name='Step 2', sequence=20) + s3 = self._make_step(name='Step 3', sequence=30) + self.assertEqual(self.job.step_count, 3) + self.assertEqual(self.job.step_done_count, 0) + s1.state = 'done' + # Force recompute + self.job.invalidate_recordset(['step_done_count', 'step_progress_pct']) + self.assertEqual(self.job.step_done_count, 1) + self.assertAlmostEqual(self.job.step_progress_pct, 33.33, places=1)