PV-T1: fp.work.centre.area_kind Selection (9 floor columns)
PV-T2: fp.job.step.area_kind compute + _STEP_KIND_TO_AREA fallback
(covers all 30+ step kinds in the project library, plus the
spec D4 rule that de_mask folds into de_racking)
PV-T3: fp.job.step.last_activity_at + write hook + message_post
override + fp.job.step.move.create() hook + _fp_is_idle helper
PV-T4: res.users.paired_work_centre_ids M2M (single-station for MVP,
forward-compatible for Phase 2 multi-station picker)
PV-T5: res.config.settings.x_fc_shopfloor_layout feature flag backed
by ir.config_parameter for the landing-action resolver
Migrations:
fusion_plating 19.0.21.0.0 — backfill area_kind from kind
fusion_plating_jobs 19.0.10.24.0 — backfill last_activity_at
Deployed + verified on entech:
- 9/9 fp.work.centre rows have area_kind set
- 400/400 fp.job.step rows have area_kind + last_activity_at
- paired_work_centre_ids M2M relation table created
- All 271 modules loaded cleanly, registry rebuilt in 27s
Part of the 2026-05-23 Shop Floor plant-view kanban redesign.
Plan: docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
8.8 KiB
Python
215 lines
8.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
# Part of the Fusion Plating product family.
|
||
|
||
from markupsafe import Markup
|
||
|
||
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
|
||
|
||
class FpJobStepMove(models.Model):
|
||
"""Chain-of-custody log — one row per part-batch move.
|
||
|
||
Sub 12b: every Move Parts / Move Rack click commits one (or, for
|
||
rack moves, one-per-batch atomic) row here. Sub 12c walks these in
|
||
chronological order to render the customer CoC PDF.
|
||
"""
|
||
_name = 'fp.job.step.move'
|
||
_description = 'Fusion Plating — Job Step Move (Chain-of-Custody)'
|
||
_inherit = ['mail.thread']
|
||
_order = 'move_datetime desc, id desc'
|
||
|
||
name = fields.Char(
|
||
string='Move Reference',
|
||
default=lambda self: self.env['ir.sequence'].next_by_code(
|
||
'fp.job.step.move') or '/',
|
||
readonly=True, copy=False,
|
||
)
|
||
job_id = fields.Many2one('fp.job', string='Job',
|
||
required=True, ondelete='cascade', index=True)
|
||
from_step_id = fields.Many2one('fp.job.step', string='From Step',
|
||
ondelete='set null', index=True)
|
||
to_step_id = fields.Many2one('fp.job.step', string='To Step',
|
||
ondelete='restrict', index=True, required=True)
|
||
from_tank_id = fields.Many2one('fusion.plating.tank',
|
||
related='from_step_id.tank_id', store=True)
|
||
to_tank_id = fields.Many2one('fusion.plating.tank', string='To Tank',
|
||
ondelete='set null')
|
||
|
||
transfer_type = fields.Selection([
|
||
('step', 'Step'),
|
||
('hold', 'Hold'),
|
||
('scrap', 'Scrap'),
|
||
('rework', 'Rework'),
|
||
('split', 'Split'),
|
||
('return', 'Return'),
|
||
], string='Transfer Type', default='step', required=True)
|
||
|
||
qty_moved = fields.Integer(string='Qty Moved', required=True)
|
||
qty_available_at_move = fields.Integer(string='Qty Available')
|
||
|
||
to_location = fields.Selection([
|
||
('global', 'Global'),
|
||
('quarantine', 'Quarantine'),
|
||
('staging_a', 'Staging A'),
|
||
('staging_b', 'Staging B'),
|
||
('shipping_dock', 'Shipping Dock'),
|
||
('scrap_bin', 'Scrap Bin'),
|
||
], string='To Location', default='global')
|
||
|
||
photo_evidence_id = fields.Many2one('ir.attachment',
|
||
string='Photo Evidence', ondelete='set null')
|
||
customer_wo_count = fields.Integer(string='# Customer WOs')
|
||
|
||
rack_id = fields.Many2one('fusion.plating.rack',
|
||
string='Rack', ondelete='set null', index=True)
|
||
unrack_after_move = fields.Boolean(string='Unrack After Move')
|
||
|
||
moved_by_user_id = fields.Many2one('res.users', string='Moved By',
|
||
default=lambda self: self.env.user, required=True)
|
||
move_datetime = fields.Datetime(string='Move Time',
|
||
default=fields.Datetime.now, required=True, index=True)
|
||
|
||
transition_input_value_ids = fields.One2many(
|
||
'fp.job.step.move.input.value', 'move_id',
|
||
string='Transition Input Values',
|
||
)
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
"""Stamp last_activity_at on from_step + to_step so the plant-view
|
||
idle gate (S16) sees moves as activity. Without this, a step that
|
||
only ever gets moves (no chatter, no state edits) eventually
|
||
trips the 8-hour idle warning falsely.
|
||
"""
|
||
moves = super().create(vals_list)
|
||
Step = self.env['fp.job.step']
|
||
step_ids = set()
|
||
for m in moves:
|
||
if m.from_step_id:
|
||
step_ids.add(m.from_step_id.id)
|
||
if m.to_step_id:
|
||
step_ids.add(m.to_step_id.id)
|
||
if step_ids:
|
||
Step.browse(list(step_ids)).sudo().with_context(
|
||
tracking_disable=True,
|
||
).write({'last_activity_at': fields.Datetime.now()})
|
||
return moves
|
||
|
||
# ------------------------------------------------------------------
|
||
# S23 — required transition-input gate
|
||
# ------------------------------------------------------------------
|
||
# When the destination step has requires_transition_form=True, the
|
||
# recipe author wants chain-of-custody attestations captured on the
|
||
# move (location, photo, customer WO #, etc.). Same dormant-field
|
||
# shape as S22's signoff bug — the field existed but nothing enforced
|
||
# it. Callers (tablet controllers, future backend wizards) MUST call
|
||
# _fp_check_transition_inputs_complete() after writing values to
|
||
# transition_input_value_ids.
|
||
#
|
||
# We can't gate on create() because values are written in a separate
|
||
# call after the move row. Model-level enforcement would require
|
||
# either a deferred-commit pattern or a write hook; explicit caller
|
||
# invocation is the simplest contract.
|
||
|
||
def _fp_missing_required_transition_inputs(self):
|
||
"""Return the recordset of required transition_input prompts on
|
||
the to_step's recipe node that have NO captured value on this
|
||
move. Centralised helper — used by the gate below and by future
|
||
diagnostics."""
|
||
self.ensure_one()
|
||
Prompt = self.env['fusion.plating.process.node.input']
|
||
to_step = self.to_step_id
|
||
if not to_step or not to_step.recipe_node_id:
|
||
return Prompt
|
||
if not to_step.requires_transition_form:
|
||
return Prompt
|
||
prompts = to_step.recipe_node_id.input_ids
|
||
if 'kind' in prompts._fields:
|
||
prompts = prompts.filtered(
|
||
lambda i: i.kind == 'transition_input')
|
||
if 'collect' in prompts._fields:
|
||
prompts = prompts.filtered(lambda i: i.collect)
|
||
required_prompts = prompts.filtered(lambda i: i.required)
|
||
if not required_prompts:
|
||
return Prompt
|
||
recorded_input_ids = set(
|
||
self.transition_input_value_ids.mapped('node_input_id.id')
|
||
)
|
||
return required_prompts.filtered(
|
||
lambda p: p.id not in recorded_input_ids
|
||
)
|
||
|
||
def _fp_check_transition_inputs_complete(self):
|
||
"""Raise UserError when the destination step has
|
||
requires_transition_form=True and required transition_input
|
||
prompts haven't been recorded on this move. Audit gate — same
|
||
shape as fp.job.step._fp_check_step_inputs_complete (S21) and
|
||
._fp_check_signoff_complete (S22).
|
||
|
||
Manager bypass via context fp_skip_transition_form=True
|
||
(consistent with the existing audit-trail flag on the tablet
|
||
controllers). Bypasses are posted to chatter on the move
|
||
record naming the user.
|
||
"""
|
||
if self.env.context.get('fp_skip_transition_form'):
|
||
for move in self:
|
||
if not move.to_step_id.requires_transition_form:
|
||
continue
|
||
move.message_post(body=Markup(_(
|
||
'Transition-form gate bypassed by %s. '
|
||
'Documented deviation — required prompts not '
|
||
'recorded on this move.'
|
||
)) % self.env.user.name)
|
||
return
|
||
for move in self:
|
||
missing = move._fp_missing_required_transition_inputs()
|
||
if not missing:
|
||
continue
|
||
names = ', '.join(
|
||
'"%s"' % (p.name or '').strip() for p in missing
|
||
)
|
||
raise UserError(_(
|
||
'Move to step "%(step)s" cannot be committed — '
|
||
'%(n)s required transition prompt(s) not recorded: '
|
||
'%(names)s. Fill them in the Move dialog before '
|
||
'committing. Managers can override via context flag '
|
||
'fp_skip_transition_form=True for documented '
|
||
'deviations.'
|
||
) % {
|
||
'step': move.to_step_id.name,
|
||
'n': len(missing),
|
||
'names': names,
|
||
})
|
||
|
||
|
||
class FpJobStepMoveInputValue(models.Model):
|
||
"""Captured value for one transition-input prompt.
|
||
|
||
Each row = one author-defined prompt × one move. Snapshot of what
|
||
the operator typed at move-time. Used by Sub 12c CoC report.
|
||
"""
|
||
_name = 'fp.job.step.move.input.value'
|
||
_description = 'Fusion Plating — Captured Transition Input Value'
|
||
_order = 'move_id, id'
|
||
|
||
move_id = fields.Many2one('fp.job.step.move', string='Move',
|
||
required=True, ondelete='cascade', index=True)
|
||
template_input_id = fields.Many2one(
|
||
'fp.step.template.transition.input',
|
||
string='Template Input', ondelete='set null',
|
||
help='What was originally asked (template-level reference).')
|
||
node_input_id = fields.Many2one(
|
||
'fusion.plating.process.node.input',
|
||
string='Node Input', ondelete='set null',
|
||
help='Snapshot of the authored prompt at job-creation time.')
|
||
|
||
value_text = fields.Char(string='Text Value')
|
||
value_number = fields.Float(string='Number Value')
|
||
value_boolean = fields.Boolean(string='Yes/No Value')
|
||
value_date = fields.Datetime(string='Date Value')
|
||
value_attachment_id = fields.Many2one('ir.attachment',
|
||
string='Attachment Value', ondelete='set null')
|