- Sequence: add noupdate="1" to fp_job_sequences.xml. Without it, every module update resets number_next to 1, corrupting the live job-number stream. Matches fp_sequence_data.xml convention. - action_cancel now raises UserError on an already-cancelled job instead of silently rewriting state. Audit-grade traceability expects explicit failures. - Added TODO marker for action_hold / action_resume / action_revert_to_confirmed so future authors don't bypass the state-machine guards. - Tests: added cannot_cancel_done (covers the dead-code UserError branch) and cannot_cancel_already_cancelled. Manifest version bumped 19.0.8.2.0 -> 19.0.8.2.1. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
# -*- 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)
|
|
|
|
# ------------------------------------------------------------------
|
|
# State machine — actions
|
|
# ------------------------------------------------------------------
|
|
# TODO(fp.job state-machine completeness): action_hold, action_resume,
|
|
# action_revert_to_confirmed (rework path) — to be added when shopfloor
|
|
# / rework workflows are wired up. For now, draft → confirmed and the
|
|
# cancel paths are the only enforced transitions; everything else is
|
|
# an explicit `state` write by privileged code.
|
|
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)
|
|
if job.state == 'cancelled':
|
|
raise UserError(_(
|
|
"Job %s is already cancelled."
|
|
) % job.name)
|
|
job.state = 'cancelled'
|
|
return True
|