# -*- 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')