From 26928713d5b65797ccf0fa3ec075a942bccb1055 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 24 Apr 2026 21:29:36 -0400 Subject: [PATCH] feat(jobs): add fp.job native model with state machine Header model replacing mrp.production. mail.thread for chatter, priority/state/deadline tracking, sequence WH/JOB/00001+. Tests cover create, confirm, cancel, and forbidden double-confirm. State machine: draft -> confirmed -> in_progress -> done v ^ cancelled (rework reverts here) on_hold can be entered from confirmed or in_progress. Step relations come in Task 1.5; SO/recipe/portal/cost extension fields come in Task 1.4. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 3 +- .../fusion_plating/data/fp_job_sequences.xml | 14 +++ .../fusion_plating/models/__init__.py | 1 + .../fusion_plating/models/fp_job.py | 108 ++++++++++++++++++ .../security/ir.model.access.csv | 3 + .../fusion_plating/tests/__init__.py | 1 + .../tests/test_fp_job_state_machine.py | 46 ++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating/data/fp_job_sequences.xml create mode 100644 fusion_plating/fusion_plating/models/fp_job.py create mode 100644 fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 24ebc965..73014a4e 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.1.1', + 'version': '19.0.8.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -82,6 +82,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'security/fp_security.xml', 'security/ir.model.access.csv', 'data/fp_sequence_data.xml', + 'data/fp_job_sequences.xml', 'data/fp_process_category_data.xml', 'views/fp_process_type_views.xml', 'views/fp_work_center_views.xml', diff --git a/fusion_plating/fusion_plating/data/fp_job_sequences.xml b/fusion_plating/fusion_plating/data/fp_job_sequences.xml new file mode 100644 index 00000000..775838f0 --- /dev/null +++ b/fusion_plating/fusion_plating/data/fp_job_sequences.xml @@ -0,0 +1,14 @@ + + + + + Plating Job Sequence + fp.job + WH/JOB/ + 5 + 1 + 1 + + + diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 6c4a2388..54bc58cc 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -16,6 +16,7 @@ from . import fp_bath_parameter from . import fp_bath_replenishment_rule from . import fp_process_node from . import fp_rack +from . import fp_job 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 new file mode 100644 index 00000000..aab0e553 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_job.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# fp.job — native plating job model. +# +# Replaces mrp.production for plating. One record per shop-floor job. +# Header data lives here; per-operation detail on fp.job.step (Task 1.5). +# Recipe template (fusion.plating.process.node) is unchanged — this +# model just instantiates from it via fp.job.step.recipe_node_id. +# +# State machine: +# draft -> confirmed -> in_progress -> done +# | ^ +# v | +# cancelled (rework reverts here) +# on_hold can be entered from confirmed or in_progress. + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FpJob(models.Model): + _name = 'fp.job' + _description = 'Plating Job' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'priority desc, date_deadline asc, id desc' + _rec_name = 'name' + + name = fields.Char( + required=True, + copy=False, + readonly=True, + default=lambda self: _('New'), + index=True, + ) + state = fields.Selection( + [ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('in_progress', 'In Progress'), + ('on_hold', 'On Hold'), + ('done', 'Done'), + ('cancelled', 'Cancelled'), + ], + default='draft', + required=True, + tracking=True, + index=True, + ) + priority = fields.Selection( + [ + ('low', 'Low'), + ('normal', 'Normal'), + ('high', 'High'), + ('rush', 'Rush'), + ], + default='normal', + tracking=True, + ) + partner_id = fields.Many2one( + 'res.partner', + string='Customer', + required=True, + tracking=True, + ) + product_id = fields.Many2one('product.product', string='Reference Product') + qty = fields.Float(string='Quantity', required=True, default=1.0) + qty_done = fields.Float(string='Quantity Completed') + qty_scrapped = fields.Float(string='Quantity Scrapped') + date_deadline = fields.Datetime(string='Deadline', tracking=True) + date_planned_start = fields.Datetime(string='Planned Start') + date_started = fields.Datetime(string='Actual Start', readonly=True) + date_finished = fields.Datetime(string='Actual Finish', readonly=True) + origin = fields.Char(string='Source SO', help='Sale Order name for traceability.') + sale_order_id = fields.Many2one('sale.order', string='Sale Order') + facility_id = fields.Many2one('fusion.plating.facility', string='Facility') + manager_id = fields.Many2one('res.users', string='Plating Manager') + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + required=True, + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New') + return super().create(vals_list) + + def action_confirm(self): + for job in self: + if job.state != 'draft': + raise UserError(_( + "Job %s is in state '%s' - only draft jobs can be confirmed." + ) % (job.name, job.state)) + job.state = 'confirmed' + return True + + def action_cancel(self): + for job in self: + if job.state == 'done': + raise UserError(_( + "Job %s is done - cannot cancel." + ) % job.name) + job.state = 'cancelled' + 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 6d104220..4adae4dc 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -47,3 +47,6 @@ access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certi access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1 +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 diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 061a2c72..b2a7f2e0 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import test_fp_work_centre +from . import test_fp_job_state_machine diff --git a/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py b/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py new file mode 100644 index 00000000..4f1242d1 --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class TestFpJobStateMachine(TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Test Customer'}) + self.product = self.env['product.product'].create({'name': 'Widget'}) + + def _make_job(self, **kw): + vals = { + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 10.0, + } + vals.update(kw) + return self.env['fp.job'].create(vals) + + def test_create_lands_in_draft(self): + job = self._make_job() + self.assertEqual(job.state, 'draft') + self.assertTrue(job.name and job.name.startswith('WH/JOB/')) + + def test_action_confirm_moves_to_confirmed(self): + job = self._make_job() + job.action_confirm() + self.assertEqual(job.state, 'confirmed') + + def test_cannot_confirm_twice(self): + job = self._make_job() + job.action_confirm() + with self.assertRaises(UserError): + job.action_confirm() + + def test_cancel_from_draft(self): + job = self._make_job() + job.action_cancel() + self.assertEqual(job.state, 'cancelled') + + def test_cannot_confirm_after_cancel(self): + job = self._make_job() + job.action_cancel() + with self.assertRaises(UserError): + job.action_confirm()