241 lines
9.3 KiB
Python
241 lines
9.3 KiB
Python
# -*- 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')
|
|
time_log_ids = fields.One2many(
|
|
'fp.job.step.timelog',
|
|
'step_id',
|
|
string='Time Logs',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Equipment + audit (Task 1.6)
|
|
# oven_id is deferred to a bridge module — fusion.plating.bake.oven
|
|
# lives in fusion_plating_shopfloor and core can't depend on it.
|
|
# masking_material_id is deferred — fusion.plating.masking.material
|
|
# does not yet exist in any installed module; will be added when
|
|
# the masking model lands (likely in fusion_plating_process_en
|
|
# or a future fusion_plating_masking module).
|
|
# ------------------------------------------------------------------
|
|
bath_id = fields.Many2one('fusion.plating.bath')
|
|
tank_id = fields.Many2one('fusion.plating.tank')
|
|
rack_id = fields.Many2one('fusion.plating.rack')
|
|
signoff_user_id = fields.Many2one('res.users', readonly=True)
|
|
facility_id = fields.Many2one(
|
|
'fusion.plating.facility',
|
|
related='work_centre_id.facility_id',
|
|
store=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Plating spec (Task 1.6)
|
|
# ------------------------------------------------------------------
|
|
thickness_target = fields.Float(string='Target Thickness')
|
|
thickness_uom = fields.Selection(
|
|
[('um', 'µm'), ('mil', 'mil'), ('inch', 'in')],
|
|
default='um',
|
|
)
|
|
dwell_time_minutes = fields.Float()
|
|
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
|
|
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
|
|
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
|
|
|
|
# ------------------------------------------------------------------
|
|
# Recipe-related (Task 1.6)
|
|
# ------------------------------------------------------------------
|
|
requires_signoff = fields.Boolean(
|
|
related='recipe_node_id.requires_signoff',
|
|
store=True,
|
|
)
|
|
auto_complete = fields.Boolean(
|
|
related='recipe_node_id.auto_complete',
|
|
store=True,
|
|
)
|
|
is_manual = fields.Boolean(
|
|
related='recipe_node_id.is_manual',
|
|
store=True,
|
|
)
|
|
customer_visible = fields.Boolean(
|
|
related='recipe_node_id.customer_visible',
|
|
store=True,
|
|
)
|
|
requires_predecessor_done = fields.Boolean(
|
|
related='recipe_node_id.requires_predecessor_done',
|
|
store=True,
|
|
help='If True, button_start blocks until every earlier-sequence '
|
|
'step in this job is done/skipped/cancelled.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cost rollup (Task 1.6)
|
|
# cost_per_hour comes from fp.work.centre (Task 1.2 added it there).
|
|
# cost_total recomputes when duration_actual or rate changes.
|
|
# duration_actual is set by button_finish as the sum of timelog
|
|
# row durations (see fp.job.step.timelog).
|
|
# ------------------------------------------------------------------
|
|
cost_per_hour = fields.Monetary(
|
|
related='work_centre_id.cost_per_hour',
|
|
currency_field='currency_id',
|
|
)
|
|
cost_total = fields.Monetary(
|
|
compute='_compute_cost_total',
|
|
store=True,
|
|
currency_field='currency_id',
|
|
)
|
|
currency_id = fields.Many2one(
|
|
'res.currency',
|
|
related='work_centre_id.currency_id',
|
|
)
|
|
|
|
@api.depends('duration_actual', 'cost_per_hour')
|
|
def _compute_cost_total(self):
|
|
for step in self:
|
|
step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour
|
|
|
|
# ------------------------------------------------------------------
|
|
# State machine — actions
|
|
# ------------------------------------------------------------------
|
|
# Implemented: button_start (ready/paused → in_progress),
|
|
# button_finish (in_progress → done).
|
|
# Stubs (raise NotImplementedError; wiring deferred):
|
|
# button_pause (in_progress → paused)
|
|
# button_resume (covered by button_start when state='paused')
|
|
# button_skip (pending/ready → skipped)
|
|
# button_cancel (any non-done → cancelled)
|
|
# Predecessor-driven transition pending → ready will be wired
|
|
# alongside first-step / dependency logic in a future task.
|
|
# ------------------------------------------------------------------
|
|
|
|
def button_pause(self):
|
|
raise NotImplementedError(_(
|
|
"button_pause is not yet implemented (operator pause / break / "
|
|
"end-of-shift). Use button_finish to complete a step or set "
|
|
"state directly via privileged code."
|
|
))
|
|
|
|
def button_skip(self):
|
|
raise NotImplementedError(_(
|
|
"button_skip is not yet implemented (skip an opt-in step that "
|
|
"wasn't activated for this job)."
|
|
))
|
|
|
|
def button_cancel(self):
|
|
raise NotImplementedError(_(
|
|
"button_cancel is not yet implemented (cancelling a single step; "
|
|
"cancelling the whole job runs through fp.job.action_cancel)."
|
|
))
|
|
|
|
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))
|
|
now = fields.Datetime.now()
|
|
step.state = 'in_progress'
|
|
# First-start audit (mirrors button_finish first-finish guard)
|
|
if not step.date_started:
|
|
step.date_started = now
|
|
step.started_by_user_id = self.env.user
|
|
# Open a fresh timelog row for this start interval — uses the
|
|
# same `now` as the first-start stamp so the step and its
|
|
# first log share a single instant.
|
|
self.env['fp.job.step.timelog'].create({
|
|
'step_id': step.id,
|
|
'user_id': self.env.user.id,
|
|
'date_started': now,
|
|
})
|
|
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))
|
|
now = fields.Datetime.now()
|
|
# Close the open timelog (the one with no date_finished)
|
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
|
open_log.write({'date_finished': now})
|
|
step.state = 'done'
|
|
# First-finish audit (mirrors button_start first-start guard)
|
|
if not step.date_finished:
|
|
step.date_finished = now
|
|
step.finished_by_user_id = self.env.user
|
|
# Sum of all interval durations becomes duration_actual
|
|
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
|
|
return True
|