feat(jobs): add fp.job.step model with state machine
Per-operation model replacing mrp.workorder for plating. Each step instantiates from a recipe operation node (recipe_node_id link). Container/step nodes from the recipe template are rendered at view time via that link — they don't get rows here. See spec §5.2 Option A. 7-state machine: pending → ready → in_progress → done, plus paused, skipped, cancelled. button_start/button_finish enforce the transitions. Job header gets step_ids + step_count, step_done_count, step_progress_pct, current_step_id (computed from steps). Equipment, audit fields, plating-spec fields, time logs, and release-ready validation come in Tasks 1.6 and 1.7. Manifest 19.0.8.3.1 → 19.0.8.4.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.8.3.1',
|
'version': '19.0.8.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from . import fp_bath_replenishment_rule
|
|||||||
from . import fp_process_node
|
from . import fp_process_node
|
||||||
from . import fp_rack
|
from . import fp_rack
|
||||||
from . import fp_job
|
from . import fp_job
|
||||||
|
from . import fp_job_step
|
||||||
from . import fp_operator_certification
|
from . import fp_operator_certification
|
||||||
from . import fp_tz
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
|||||||
@@ -178,6 +178,45 @@ class FpJob(models.Model):
|
|||||||
else:
|
else:
|
||||||
job.current_location = job.state.replace('_', ' ').title()
|
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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
for vals in vals_list:
|
for vals in vals_list:
|
||||||
|
|||||||
100
fusion_plating/fusion_plating/models/fp_job_step.py
Normal file
100
fusion_plating/fusion_plating/models/fp_job_step.py
Normal file
@@ -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
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -1,3 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_fp_work_centre
|
from . import test_fp_work_centre
|
||||||
from . import test_fp_job_state_machine
|
from . import test_fp_job_state_machine
|
||||||
|
from . import test_fp_job_step_state_machine
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user