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) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.8.1.1',
|
'version': '19.0.8.2.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': """
|
||||||
@@ -82,6 +82,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'security/fp_security.xml',
|
'security/fp_security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_sequence_data.xml',
|
'data/fp_sequence_data.xml',
|
||||||
|
'data/fp_job_sequences.xml',
|
||||||
'data/fp_process_category_data.xml',
|
'data/fp_process_category_data.xml',
|
||||||
'views/fp_process_type_views.xml',
|
'views/fp_process_type_views.xml',
|
||||||
'views/fp_work_center_views.xml',
|
'views/fp_work_center_views.xml',
|
||||||
|
|||||||
14
fusion_plating/fusion_plating/data/fp_job_sequences.xml
Normal file
14
fusion_plating/fusion_plating/data/fp_job_sequences.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Sequence for fp.job. Format: WH/JOB/00001 onwards.
|
||||||
|
Migrated mrp.production records keep their WH/MO/... names. -->
|
||||||
|
<record id="seq_fp_job" model="ir.sequence">
|
||||||
|
<field name="name">Plating Job Sequence</field>
|
||||||
|
<field name="code">fp.job</field>
|
||||||
|
<field name="prefix">WH/JOB/</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="number_next">1</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -16,6 +16,7 @@ from . import fp_bath_parameter
|
|||||||
from . import fp_bath_replenishment_rule
|
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_operator_certification
|
from . import fp_operator_certification
|
||||||
from . import fp_tz
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
|||||||
108
fusion_plating/fusion_plating/models/fp_job.py
Normal file
108
fusion_plating/fusion_plating/models/fp_job.py
Normal file
@@ -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
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -1,2 +1,3 @@
|
|||||||
# -*- 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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user