Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-28-sub12b-tablet-move-rack-timer.md
gsinghpal 25b429f253 docs(sub12b): implementation plan — 18 tasks for tablet Move/Rack/Timer
Adjustments from the spec, captured upfront in the plan:
- fp.rack already exists (extend, don't create) — Task 3
- fp.labor.timer collapses into the existing fp.job.step.timelog with
  a state machine + reconciliation fields — Task 7. Avoids parallel
  labor-tracking models; keeps battle-test S1/S2 paths intact.
- Sub 12b's Save+Print on Rack Parts references a report that lands
  in Sub 12c — flagged in Task 12 body.

18 tasks cover: 4 new models (rack tag, move, move input value), state
machine on existing rack + timelog, 11 controller endpoints, 4 OWL
dialogs, plant overview 2-pane layout, runtime guards, manager bypass
flags, entech deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:55:04 -04:00

103 KiB
Raw Blame History

Sub 12b — Move Parts / Move Rack / Persistent Labor Timer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bring Steelhead-style Move Parts / Move Rack / Rack Parts / Stop Timer dialogs to the tablet, with author-defined transition prompts (from Sub 12a's transition_input_ids), photo-evidence capture, and the soft/hard block UX with inline resolution buttons (our improvement over Steelhead's dead-end warnings).

Architecture: Extend existing fusion.plating.rack (already in core) with state-machine fields. Add 3 new models: fp.rack.tag (M2M tag registry), fp.job.step.move (chain-of-custody log), fp.job.step.move.input.value (captured transition values). Extend existing fp.job.step.timelog with state machine + billed reconciliation fields (matches single-source-of-truth pattern; avoids parallel labor model). New OWL dialogs in fusion_plating_shopfloor/static/src/js/. New tablet controller endpoints in fusion_plating_shopfloor/controllers/.

Tech Stack: Odoo 19, Python 3.11, OWL 2 (@odoo/owl), @web/core/network/rpc, SCSS, QWeb XML for OWL templates.

Companion docs:

Deploy target: entech (LXC 111 on pve-worker5). Verification = -u --stop-after-init clean upgrade + manual smoke test on real-data tablet flows. Sub 12a (v19.0.10.0.0) must already be deployed.


File Structure

Files to create

fusion_plating/models/fp_rack_tag.py                                # fp.rack.tag (M2M tag registry)
fusion_plating/models/fp_job_step_move.py                           # fp.job.step.move + child fp.job.step.move.input.value
fusion_plating/views/fp_rack_tag_views.xml                          # tag CRUD
fusion_plating/views/fp_job_step_move_views.xml                     # move log list/form
fusion_plating_shopfloor/controllers/move_controller.py             # /fp/tablet/move_*, /fp/tablet/rack_parts/*, /fp/tablet/labor_timer/*
fusion_plating_shopfloor/static/src/js/move_parts_dialog.js         # Move Parts dialog component
fusion_plating_shopfloor/static/src/js/move_rack_dialog.js          # Move Rack dialog component
fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js         # Rack Parts sub-dialog
fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js         # Stop User Labor Timer dialog
fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml       # OWL templates (one file per dialog → easier to find)
fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml
fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml
fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml
fusion_plating_shopfloor/static/src/scss/move_dialogs.scss          # shared SCSS for all 4 dialogs

Files to modify

fusion_plating/__manifest__.py                                      # version 19.0.10.0.0 → 19.0.10.1.0
fusion_plating/models/__init__.py                                   # import fp_rack_tag + fp_job_step_move
fusion_plating/models/fp_rack.py                                    # extend state, add tag_ids, current_job_step_id, current_part_count, capacity_count
fusion_plating/models/fp_job_step.py                                # add current_rack_id, is_racked, qty_at_step_start/finish, move_ids
fusion_plating/models/fp_job.py                                     # add qty_received, qty_visual_inspection_rejects, qty_rework, special_requirements, active_timer_ids
fusion_plating/models/fp_job_step_timelog.py                        # extend with state, billed_*, product_id, accrued_seconds compute
fusion_plating/views/fp_rack_views.xml                              # surface new fields on rack form
fusion_plating/security/ir.model.access.csv                         # ACLs for the 3 new models
fusion_plating/data/fp_sequence_data.xml                            # FP/MOVE/YYYY/NNNN sequence (move log) + FP/TIMER/YYYY/NNNN
fusion_plating_shopfloor/__manifest__.py                            # version bump + new files in assets + data
fusion_plating_shopfloor/controllers/__init__.py                    # import move_controller
fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js          # wire Move Parts / Stop Timer buttons; rack-vs-parts visibility guard
fusion_plating_shopfloor/static/src/js/plant_overview.js            # add Racks pane + UNRACK MULTIPLE bulk action
fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml        # template tweaks for new buttons
fusion_plating_shopfloor/static/src/xml/plant_overview.xml          # 2-pane layout

Conventions for every task

  • Read files before editing (Odoo CLAUDE.md rule).
  • Headers: # -*- coding: utf-8 -*- + Copyright + License + Part-of comments.
  • Field naming: standard Odoo models = x_fc_* prefix; our custom models = plain names.
  • Verification = entech upgrade: per the user's workflow (option B from Sub 12a execution), local TDD is skipped. Each task's verification step is -u fusion_plating --stop-after-init clean upgrade. Smoke test on tablet at the end.
  • Frequent commits — every task ends with a commit on main.
  • Deploy command:
    tar -cf - <files> | ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar -xf -'"
    ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
      su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
        -u fusion_plating --stop-after-init\" 2>&1 | tail -20 && \
      systemctl start odoo'"
    

Task 1: Bump versions + scaffold manifests

Files:

  • Modify: fusion_plating/__manifest__.py

  • Modify: fusion_plating_shopfloor/__manifest__.py

  • Step 1: Bump fusion_plating version

# fusion_plating/__manifest__.py
'version': '19.0.10.0.0'  '19.0.10.1.0',

Add to 'data' list (after views/fp_step_template_views.xml):

        'views/fp_rack_tag_views.xml',
        'views/fp_job_step_move_views.xml',
  • Step 2: Bump fusion_plating_shopfloor version

Read it first to find current version:

grep "version" fusion_plating_shopfloor/__manifest__.py | head -1

Bump to next patch number (e.g. if current is 19.0.24.4.0, bump to 19.0.25.0.0).

Add to 'assets' → 'web.assets_backend':

            'fusion_plating_shopfloor/static/src/scss/move_dialogs.scss',
            'fusion_plating_shopfloor/static/src/js/move_parts_dialog.js',
            'fusion_plating_shopfloor/static/src/js/move_rack_dialog.js',
            'fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js',
            'fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js',
            'fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml',
            'fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml',
            'fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml',
            'fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml',
  • Step 3: Commit
git add fusion_plating/__manifest__.py fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(sub12b): bump versions + scaffold manifests

fusion_plating → 19.0.10.1.0
fusion_plating_shopfloor → next patch

Adds data entries for the 2 new view files (rack_tag + job_step_move).
Adds 4 OWL dialogs + their templates + shared SCSS to the shopfloor
backend asset bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: Add fp.rack.tag model (rack-label registry)

Files:

  • Create: fusion_plating/models/fp_rack_tag.py

  • Create: fusion_plating/views/fp_rack_tag_views.xml

  • Modify: fusion_plating/models/__init__.py

  • Step 1: Create the model

fusion_plating/models/fp_rack_tag.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.

from odoo import fields, models


class FpRackTag(models.Model):
    """Operator-visible labels applied to physical racks.

    "Rush" / "Hold for QC" / "Customer-Amphenol" / "Damaged" — the
    coloured tag chips that appear in the Move Rack dialog and on the
    plant-overview rack rows. M2M; one rack can carry many tags.
    """
    _name = 'fp.rack.tag'
    _description = 'Fusion Plating — Rack Tag'
    _order = 'sequence, name'

    name = fields.Char(string='Tag', required=True, translate=True)
    color = fields.Integer(string='Color')
    sequence = fields.Integer(string='Sequence', default=10)
    active = fields.Boolean(string='Active', default=True)
    company_id = fields.Many2one(
        'res.company', string='Company',
        default=lambda self: self.env.company,
    )

    _sql_constraints = [
        ('fp_rack_tag_name_company_uniq',
         'unique(name, company_id)',
         'Rack tag name must be unique within a company.'),
    ]
  • Step 2: Create the views

fusion_plating/views/fp_rack_tag_views.xml:

<?xml version="1.0" encoding="utf-8"?>
<!--
    Copyright 2026 Nexa Systems Inc.
    License OPL-1 (Odoo Proprietary License v1.0)
    Part of the Fusion Plating product family.
-->
<odoo>

    <record id="view_fp_rack_tag_list" model="ir.ui.view">
        <field name="name">fp.rack.tag.list</field>
        <field name="model">fp.rack.tag</field>
        <field name="arch" type="xml">
            <list string="Rack Tags" editable="bottom">
                <field name="sequence" widget="handle"/>
                <field name="name"/>
                <field name="color" widget="color_picker"/>
                <field name="active" widget="boolean_toggle"/>
            </list>
        </field>
    </record>

    <record id="action_fp_rack_tag" model="ir.actions.act_window">
        <field name="name">Rack Tags</field>
        <field name="res_model">fp.rack.tag</field>
        <field name="view_mode">list</field>
    </record>

    <menuitem id="menu_fp_rack_tags"
              name="Rack Tags"
              parent="menu_fp_config"
              action="action_fp_rack_tag"
              sequence="48"/>

</odoo>
  • Step 3: Wire into init

In fusion_plating/models/__init__.py, add after the Sub 12a block:

# Sub 12b — Rack-aware moves + persistent labor reconciliation
from . import fp_rack_tag
  • Step 4: ACL rows

Append to fusion_plating/security/ir.model.access.csv:

access_fp_rack_tag_operator,fp.rack.tag.operator,model_fp_rack_tag,group_fusion_plating_operator,1,0,0,0
access_fp_rack_tag_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,group_fusion_plating_supervisor,1,1,1,1
access_fp_rack_tag_manager,fp.rack.tag.manager,model_fp_rack_tag,group_fusion_plating_manager,1,1,1,1
  • Step 5: Seed 4 starter tags via post_init_hook

In fusion_plating/__init__.py, append a new helper called from the existing hook:

def post_init_hook(env):
    _seed_default_timezone(env)
    _backfill_node_input_kind(env)
    _seed_step_library_if_empty(env)
    _seed_rack_tags_if_empty(env)   # NEW


def _seed_rack_tags_if_empty(env):
    """Sub 12b — seed 4 starter rack tags."""
    Tag = env['fp.rack.tag']
    if Tag.search_count([]):
        return
    starters = [
        ('Rush', 1),
        ('Hold for QC', 3),
        ('Damaged', 9),
        ('Customer Sample', 5),
    ]
    for name, color in starters:
        Tag.create({'name': name, 'color': color})
    _logger.info(
        'Fusion Plating: seeded %s starter rack tags', len(starters),
    )
  • Step 6: Commit
git add fusion_plating/models/fp_rack_tag.py \
        fusion_plating/models/__init__.py \
        fusion_plating/views/fp_rack_tag_views.xml \
        fusion_plating/security/ir.model.access.csv \
        fusion_plating/__init__.py
git commit -m "feat(sub12b): fp.rack.tag — rack-label registry

M2M tag registry: Rush / Hold for QC / Damaged / Customer Sample.
Each rack can carry many tags; tags surface as coloured chips in the
Move Rack dialog and on plant-overview rack rows.

Seeded with 4 starter tags via post_init_hook (idempotent).
Plating → Configuration → Rack Tags menu added (sequence 48).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Extend fusion.plating.rack with state machine + new fields

Files:

  • Modify: fusion_plating/models/fp_rack.py

  • Modify: fusion_plating/views/fp_rack_views.xml

  • Step 1: Read existing model + view

cat fusion_plating/models/fp_rack.py
grep -n "view_fp_rack" fusion_plating/views/fp_rack_views.xml
  • Step 2: Extend fusion.plating.rack

Add to the model class:

    # ===== Sub 12b — state machine + tags + capacity =====================
    state = fields.Selection(
        [
            ('empty', 'Empty'),
            ('loading', 'Loading'),
            ('loaded', 'Loaded'),
            ('in_use', 'In Use'),
            ('awaiting_unrack', 'Awaiting Unrack'),
            ('out_of_service', 'Out of Service'),
        ],
        string='State', default='empty', tracking=True,
    )
    tag_ids = fields.Many2many(
        'fp.rack.tag',
        'fp_rack_tag_rel', 'rack_id', 'tag_id',
        string='Tags',
    )
    capacity_count = fields.Integer(
        string='Capacity (parts)',
        help='Soft warning threshold — runtime informs operator when '
             'rack is loaded beyond this. Not enforced.',
    )
    notes = fields.Text(string='Maintenance Notes')

    current_job_step_id = fields.Many2one(
        'fp.job.step', string='Current Step',
        compute='_compute_current_state',
        store=True,
    )
    current_tank_id = fields.Many2one(
        'fusion.plating.tank', string='Current Tank',
        related='current_job_step_id.tank_id',
        store=True,
    )
    current_part_count = fields.Integer(
        string='Parts on Rack',
        compute='_compute_current_state',
        store=True,
    )

    @api.depends('state')  # trigger; real source is fp.job.step.move via inverse
    def _compute_current_state(self):
        # Walk the most recent FP move(s) per rack to find current job step + qty.
        # For racks with no in-flight moves, all values are blank.
        Move = self.env['fp.job.step.move']
        for rack in self:
            if rack.state in ('empty', 'out_of_service'):
                rack.current_job_step_id = False
                rack.current_part_count = 0
                continue
            recent = Move.search(
                [('rack_id', '=', rack.id)],
                order='move_datetime desc',
                limit=1,
            )
            rack.current_job_step_id = recent.to_step_id if recent else False
            rack.current_part_count = sum(
                Move.search(
                    [('rack_id', '=', rack.id),
                     ('move_datetime', '>=', recent.move_datetime if recent else False)]
                ).mapped('qty_moved')
            ) if recent else 0
  • Step 3: Surface the new fields on the rack form

In fusion_plating/views/fp_rack_views.xml, find the existing form view and add a state header + new fields:

        <field name="arch" type="xml">
            <data>
                <xpath expr="//header" position="inside">
                    <field name="state" widget="statusbar"
                           statusbar_visible="empty,loading,loaded,in_use,awaiting_unrack"/>
                </xpath>
                <xpath expr="//sheet" position="inside">
                    <group>
                        <group string="Tags + Capacity">
                            <field name="tag_ids" widget="many2many_tags"
                                   options="{'color_field': 'color'}"/>
                            <field name="capacity_count"/>
                        </group>
                        <group string="Current Use" invisible="state in ('empty','out_of_service')">
                            <field name="current_job_step_id" readonly="1"/>
                            <field name="current_tank_id" readonly="1"/>
                            <field name="current_part_count" readonly="1"/>
                        </group>
                    </group>
                    <group string="Notes">
                        <field name="notes" nolabel="1"/>
                    </group>
                </xpath>
            </data>
        </field>

(If no <header> exists in the rack view, add one with <xpath expr="//form" position="inside"> instead.)

  • Step 4: Commit
git add fusion_plating/models/fp_rack.py fusion_plating/views/fp_rack_views.xml
git commit -m "feat(sub12b): extend fusion.plating.rack — state + tags + capacity

State machine: empty → loading → loaded → in_use → awaiting_unrack →
empty (or out_of_service from any state).

New fields: tag_ids (M2M to fp.rack.tag), capacity_count (soft warn),
notes, plus 3 computes (current_job_step_id, current_tank_id,
current_part_count) that walk fp.job.step.move history.

The current_* fields stay correct as moves happen — the compute
re-fires when state changes (and the controller bumps state on every
move-rack call).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Create fp.job.step.move + fp.job.step.move.input.value

Files:

  • Create: fusion_plating/models/fp_job_step_move.py

  • Modify: fusion_plating/models/__init__.py

  • Modify: fusion_plating/data/fp_sequence_data.xml

  • Step 1: Add the move-log sequence

Append to fusion_plating/data/fp_sequence_data.xml (inside the existing <odoo> root):

    <record id="seq_fp_job_step_move" model="ir.sequence">
        <field name="name">FP — Move Log</field>
        <field name="code">fp.job.step.move</field>
        <field name="prefix">FP/MOVE/%(year)s/</field>
        <field name="padding">5</field>
        <field name="company_id" eval="False"/>
    </record>

    <record id="seq_fp_labor_timer" model="ir.sequence">
        <field name="name">FP — Labor Timer</field>
        <field name="code">fp.labor.timer</field>
        <field name="prefix">FP/TIMER/%(year)s/</field>
        <field name="padding">5</field>
        <field name="company_id" eval="False"/>
    </record>
  • Step 2: Create the move-log model file

fusion_plating/models/fp_job_step_move.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.

from odoo import api, fields, models


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='set null', 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',
    )


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')
  • Step 3: Wire into init

fusion_plating/models/__init__.py — add:

from . import fp_job_step_move
  • Step 4: ACL rows

Append to fusion_plating/security/ir.model.access.csv:

access_fp_job_step_move_operator,fp.job.step.move.operator,model_fp_job_step_move,group_fusion_plating_operator,1,1,1,0
access_fp_job_step_move_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move,group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,group_fusion_plating_operator,1,1,1,0
access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,group_fusion_plating_manager,1,1,1,1

(Operators get read+write+create — they generate moves at runtime — but no unlink. Manager-only deletes for audit safety.)

  • Step 5: Commit
git add fusion_plating/models/fp_job_step_move.py \
        fusion_plating/models/__init__.py \
        fusion_plating/data/fp_sequence_data.xml \
        fusion_plating/security/ir.model.access.csv
git commit -m "feat(sub12b): fp.job.step.move + fp.job.step.move.input.value

Chain-of-custody log: one row per Move Parts / Move Rack commit.
FP/MOVE/YYYY/NNNN sequence. Carries from/to step, tank, transfer
type, qty, location, photo, rack, operator, datetime.

Child model captures recorded transition-input values (Sub 12a's
fp.step.template.transition.input snapshots → fp.job.step.move.
input.value rows). Each row carries 5 typed value columns; the
controller picks the right one based on input_type.

Operators can create + write but not unlink (audit safety). Manager
override available for genuine cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Create move-log views (audit list/form)

Files:

  • Create: fusion_plating/views/fp_job_step_move_views.xml

  • Step 1: Create the views

<?xml version="1.0" encoding="utf-8"?>
<!--
    Copyright 2026 Nexa Systems Inc.
    License OPL-1 (Odoo Proprietary License v1.0)
    Part of the Fusion Plating product family.
-->
<odoo>

    <record id="view_fp_job_step_move_list" model="ir.ui.view">
        <field name="name">fp.job.step.move.list</field>
        <field name="model">fp.job.step.move</field>
        <field name="arch" type="xml">
            <list string="Move Log" default_order="move_datetime desc">
                <field name="name"/>
                <field name="move_datetime"/>
                <field name="job_id"/>
                <field name="from_step_id"/>
                <field name="to_step_id"/>
                <field name="from_tank_id"/>
                <field name="to_tank_id"/>
                <field name="qty_moved"/>
                <field name="transfer_type" widget="badge"/>
                <field name="rack_id"/>
                <field name="moved_by_user_id"/>
            </list>
        </field>
    </record>

    <record id="view_fp_job_step_move_form" model="ir.ui.view">
        <field name="name">fp.job.step.move.form</field>
        <field name="model">fp.job.step.move</field>
        <field name="arch" type="xml">
            <form string="Move" create="false">
                <sheet>
                    <div class="oe_title"><h1><field name="name" readonly="1"/></h1></div>
                    <group>
                        <group>
                            <field name="job_id"/>
                            <field name="from_step_id"/>
                            <field name="to_step_id"/>
                            <field name="transfer_type"/>
                            <field name="qty_moved"/>
                            <field name="qty_available_at_move"/>
                        </group>
                        <group>
                            <field name="from_tank_id"/>
                            <field name="to_tank_id"/>
                            <field name="to_location"/>
                            <field name="rack_id"/>
                            <field name="customer_wo_count"/>
                            <field name="moved_by_user_id"/>
                            <field name="move_datetime"/>
                        </group>
                    </group>
                    <notebook>
                        <page string="Captured Inputs" name="captured_inputs">
                            <field name="transition_input_value_ids" readonly="1">
                                <list>
                                    <field name="node_input_id"/>
                                    <field name="value_text"/>
                                    <field name="value_number"/>
                                    <field name="value_boolean"/>
                                    <field name="value_date"/>
                                    <field name="value_attachment_id"/>
                                </list>
                            </field>
                        </page>
                        <page string="Photo Evidence" name="photo"
                              invisible="not photo_evidence_id">
                            <field name="photo_evidence_id" widget="image"/>
                        </page>
                    </notebook>
                </sheet>
                <chatter/>
            </form>
        </field>
    </record>

    <record id="action_fp_job_step_move" model="ir.actions.act_window">
        <field name="name">Move Log</field>
        <field name="res_model">fp.job.step.move</field>
        <field name="view_mode">list,form</field>
    </record>

    <menuitem id="menu_fp_job_step_move"
              name="Move Log"
              parent="menu_fp_root"
              action="action_fp_job_step_move"
              sequence="62"/>

</odoo>
  • Step 2: Commit
git add fusion_plating/views/fp_job_step_move_views.xml
git commit -m "feat(sub12b): move-log list/form views + Plating menu entry

Plating → Move Log (sequence 62, between Logistics 60 and Aerospace 65).
Form is read-only (create=false) since moves are produced by the
tablet flow, not the desktop UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: Extend fp.job.step + fp.job with new fields

Files:

  • Modify: fusion_plating/models/fp_job_step.py

  • Modify: fusion_plating/models/fp_job.py

  • Step 1: Read both files to find safe insertion points

grep -n "_name\|_inherit\|class\|_sql_constraints" fusion_plating/models/fp_job_step.py
grep -n "_name\|_inherit\|class\|_sql_constraints" fusion_plating/models/fp_job.py
  • Step 2: Extend fp.job.step

Add these fields after the existing field block:

    # ===== Sub 12b — chain-of-custody + rack awareness =====================
    move_ids = fields.One2many(
        'fp.job.step.move', 'from_step_id',
        string='Outgoing Moves',
    )
    incoming_move_ids = fields.One2many(
        'fp.job.step.move', 'to_step_id',
        string='Incoming Moves',
    )
    current_rack_id = fields.Many2one(
        'fusion.plating.rack', string='Current Rack',
        ondelete='set null', index=True,
        help='When set, this batch of parts is loaded on a rack and '
             'moves only via the Move Rack flow. The Move Parts button '
             'on the tablet greys out.',
    )
    is_racked = fields.Boolean(
        string='Racked', compute='_compute_is_racked', store=True,
    )
    qty_at_step_start = fields.Integer(string='Qty at Step Start')
    qty_at_step_finish = fields.Integer(string='Qty at Step Finish')

    @api.depends('current_rack_id')
    def _compute_is_racked(self):
        for rec in self:
            rec.is_racked = bool(rec.current_rack_id)
  • Step 3: Extend fp.job

Add these fields after the existing field block:

    # ===== Sub 12b — traveller header + active timer ========================
    qty_received = fields.Integer(string='Qty Received',
        help='From paper traveller "Qty Rec." column.')
    qty_visual_inspection_rejects = fields.Integer(string='Visual Insp Rejects',
        help='From paper traveller "VIS INSP." column.')
    qty_rework = fields.Integer(string='Qty Sent to Rework',
        help='From paper traveller "Rework" column.')
    special_requirements = fields.Text(string='Special Requirements',
        help='Long free-form spec text from customer; printed on '
             'traveller header.')
    active_timer_ids = fields.One2many(
        'fp.job.step.timelog', 'job_id',
        string='Active Timers',
        domain=[('state', 'in', ('running', 'paused'))],
    )

(Note: fp.job.step.timelog will get the state + job_id fields in Task 7.)

  • Step 4: Commit
git add fusion_plating/models/fp_job_step.py fusion_plating/models/fp_job.py
git commit -m "feat(sub12b): fp.job.step + fp.job — rack + move + traveller header

fp.job.step:
  + move_ids (O2M outgoing) + incoming_move_ids
  + current_rack_id, is_racked (compute, stored)
  + qty_at_step_start, qty_at_step_finish

fp.job:
  + qty_received, qty_visual_inspection_rejects, qty_rework
  + special_requirements (Text — long customer-spec callout)
  + active_timer_ids (filtered O2M, used by tablet for live timer
    badge display)

All additive. No removed fields. Existing battle tests unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Extend fp.job.step.timelog with persistent state machine

Files:

  • Modify: fusion_plating/models/fp_job_step_timelog.py

  • Step 1: Read the existing timelog

cat fusion_plating/models/fp_job_step_timelog.py
  • Step 2: Add state + reconciliation fields

Append to the class:

    # ===== Sub 12b — persistent timer state machine =========================
    state = fields.Selection(
        [
            ('running', 'Running'),
            ('paused', 'Paused'),
            ('stopped', 'Stopped'),
            ('reconciled', 'Reconciled'),
        ],
        string='State', default='running', tracking=True,
    )
    job_id = fields.Many2one(
        'fp.job', related='step_id.job_id',
        store=True, string='Job', index=True,
    )
    last_paused_at = fields.Datetime(string='Last Paused')
    total_paused_seconds = fields.Integer(
        string='Total Paused (sec)', default=0,
        help='Cumulative time spent in paused state since started_at.',
    )
    accrued_seconds = fields.Integer(
        string='Accrued (sec)',
        compute='_compute_accrued_seconds',
        help='Live seconds since started_at, minus total_paused_seconds. '
             'Recomputed on read for running timers; frozen for stopped/'
             'reconciled.',
    )
    billed_hrs = fields.Integer(string='Billed Hours')
    billed_min = fields.Integer(string='Billed Minutes')
    billed_sec = fields.Integer(string='Billed Seconds')
    billed_total_seconds = fields.Integer(
        string='Billed Total (sec)',
        compute='_compute_billed_total_seconds', store=True,
    )
    billed_pct = fields.Float(
        string='% Billed', compute='_compute_billed_pct',
        help='billed_total / accrued × 100',
    )
    product_id = fields.Many2one(
        'product.product', string='Reconciled Product',
        help='When the operator splits a timer across multiple products, '
             'this row carries the destination product.',
    )
    notes = fields.Text(string='Operator Notes')

    @api.depends(
        'state', 'started_at', 'stopped_at', 'last_paused_at',
        'total_paused_seconds',
    )
    def _compute_accrued_seconds(self):
        from datetime import datetime
        now = fields.Datetime.now()
        for rec in self:
            if not rec.started_at:
                rec.accrued_seconds = 0
                continue
            end = rec.stopped_at or now
            elapsed = (end - rec.started_at).total_seconds()
            rec.accrued_seconds = max(0, int(elapsed) - (rec.total_paused_seconds or 0))

    @api.depends('billed_hrs', 'billed_min', 'billed_sec')
    def _compute_billed_total_seconds(self):
        for rec in self:
            rec.billed_total_seconds = (
                (rec.billed_hrs or 0) * 3600
                + (rec.billed_min or 0) * 60
                + (rec.billed_sec or 0)
            )

    @api.depends('billed_total_seconds', 'accrued_seconds')
    def _compute_billed_pct(self):
        for rec in self:
            if rec.accrued_seconds:
                rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds
            else:
                rec.billed_pct = 0.0

(Note: this assumes the existing timelog has step_id, started_at, stopped_at fields — typical for a timelog. If field names differ, adjust the related/depends accordingly.)

  • Step 3: Commit
git add fusion_plating/models/fp_job_step_timelog.py
git commit -m "feat(sub12b): persistent state machine on fp.job.step.timelog

Extends the existing timelog (used by S1/S2 battle tests) with:
- state: running / paused / stopped / reconciled
- last_paused_at, total_paused_seconds (drives accrued compute)
- accrued_seconds (compute, live for running rows)
- billed_hrs/min/sec + billed_total_seconds + billed_pct (compute)
- product_id (for split-by-product reconciliation per screen 10)
- notes
- job_id (related, indexed — for fast O2M from fp.job.active_timer_ids)

The existing battle tests use the timelog without state — default is
'running' so they're unaffected. State only flips when Sub 12b's Stop
Timer dialog commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 8: Tablet controller — Move Parts endpoints

Files:

  • Create: fusion_plating_shopfloor/controllers/move_controller.py

  • Modify: fusion_plating_shopfloor/controllers/__init__.py

  • Step 1: Create the controller skeleton + Move Parts endpoints

fusion_plating_shopfloor/controllers/move_controller.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Tablet endpoints for Sub 12b — Move Parts / Move Rack / Rack Parts /
Stop Timer dialogs.

All routes JSONRPC, auth='user'. Errors return {ok: False, error: msg}
so the tablet UI can show a flash without crashing the OWL component.
"""

from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request


# Selection of fp.job.step.move fields the preview endpoint exposes.
_MOVE_PREVIEW_FIELDS = [
    'qty_available', 'from_node_name', 'from_tank_name',
    'to_node_name', 'to_tank_options', 'transition_prompts',
    'blockers',
]


class FpTabletMoveController(http.Controller):
    """Move Parts / Move Rack / Rack Parts / Stop Timer."""

    # ------------------------------------------------------------- helpers

    def _safe(self, method, *args, **kwargs):
        """Wrap UserError → JSONRPC-friendly {ok: False, error: msg}."""
        try:
            return {'ok': True, **method(*args, **kwargs)}
        except UserError as exc:
            return {'ok': False, 'error': str(exc.args[0])}

    def _step_payload(self, step):
        return {
            'id': step.id,
            'name': step.name,
            'tank_id': step.tank_id.id if step.tank_id else False,
            'tank_name': step.tank_id.name if step.tank_id else '',
            'tank_options': [
                {'id': t.id, 'name': t.name, 'code': t.code}
                for t in step.tank_ids
            ] if 'tank_ids' in step._fields else [],
            'requires_rack_assignment': step.requires_rack_assignment,
            'requires_transition_form': step.requires_transition_form,
        }

    def _transition_prompts_for(self, step):
        """Returns the snapshot transition_input rows on a step."""
        prompts = []
        for inp in step.input_ids.filtered(lambda i: i.kind == 'transition_input'):
            prompts.append({
                'id': inp.id,
                'name': inp.name,
                'input_type': inp.input_type,
                'required': inp.required,
                'hint': inp.hint or '',
                'selection_options': inp.selection_options or '',
                'compliance_tag': inp.compliance_tag,
            })
        return prompts

    def _blockers_for_move(self, from_step, to_step, qty):
        """Compute the blockers list. Each blocker has type/severity/message/
        resolve_action keys; the tablet renders them with inline buttons."""
        blockers = []
        # 1. Rack-required gate (soft block — operator may RACK PARTS)
        if to_step.requires_rack_assignment and not from_step.current_rack_id:
            blockers.append({
                'type': 'rack_required',
                'severity': 'soft',
                'message': _("Parts must be racked before moving to %s.") % to_step.name,
                'resolve_action': 'open_rack_parts_dialog',
            })
        # 2. Predecessor lock (S14 hard block)
        if to_step.requires_predecessor_done:
            unfinished = to_step.job_id.step_ids.filtered(
                lambda s: s.sequence < to_step.sequence
                and s.state not in ('done', 'skipped', 'cancelled')
            )
            if unfinished:
                blockers.append({
                    'type': 'predecessor_lock',
                    'severity': 'hard',
                    'message': _("Predecessor not done: %s") % unfinished[0].name,
                    'resolve_action': 'open_predecessor_step',
                    'resolve_step_id': unfinished[0].id,
                })
        return blockers

    # ----------------------------------------------------- /move_parts/preview
    @http.route('/fp/tablet/move_parts/preview', type='jsonrpc', auth='user')
    def move_parts_preview(self, from_step_id, to_step_id, qty=None):
        Step = request.env['fp.job.step']
        from_step = Step.browse(from_step_id)
        to_step = Step.browse(to_step_id)
        qty = qty or (from_step.qty_done - from_step.qty_scrapped)
        return {
            'ok': True,
            'qty_available': qty,
            'from_step': self._step_payload(from_step),
            'to_step': self._step_payload(to_step),
            'transition_prompts': self._transition_prompts_for(to_step),
            'blockers': self._blockers_for_move(from_step, to_step, qty),
        }

    # ------------------------------------------------------ /move_parts/commit
    @http.route('/fp/tablet/move_parts/commit', type='jsonrpc', auth='user')
    def move_parts_commit(self, from_step_id, to_step_id, qty,
                          transfer_type='step', to_tank_id=False,
                          to_location='global', photo_attachment_id=False,
                          customer_wo_count=0, prompt_values=None):
        return self._safe(self._do_move_parts_commit,
            from_step_id, to_step_id, qty, transfer_type,
            to_tank_id, to_location, photo_attachment_id,
            customer_wo_count, prompt_values or {},
        )

    def _do_move_parts_commit(self, from_step_id, to_step_id, qty,
                              transfer_type, to_tank_id, to_location,
                              photo_attachment_id, customer_wo_count,
                              prompt_values):
        Step = request.env['fp.job.step']
        Move = request.env['fp.job.step.move']
        from_step = Step.browse(from_step_id)
        to_step = Step.browse(to_step_id)

        # Hard-block re-check on commit (defence in depth — preview can lie
        # if state changed between preview and commit).
        blockers = self._blockers_for_move(from_step, to_step, qty)
        hard = [b for b in blockers if b['severity'] == 'hard']
        if hard:
            raise UserError(hard[0]['message'])

        move = Move.create({
            'job_id': from_step.job_id.id,
            'from_step_id': from_step.id,
            'to_step_id': to_step.id,
            'transfer_type': transfer_type,
            'qty_moved': qty,
            'qty_available_at_move': from_step.qty_done - from_step.qty_scrapped,
            'to_tank_id': to_tank_id or False,
            'to_location': to_location,
            'photo_evidence_id': photo_attachment_id or False,
            'customer_wo_count': customer_wo_count or 0,
        })

        # Capture prompt values
        for prompt_id, value in (prompt_values or {}).items():
            self._capture_prompt_value(move, int(prompt_id), value)

        # Advance qty_at_step counters
        to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
        from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty

        return {'move_id': move.id, 'move_name': move.name}

    def _capture_prompt_value(self, move, node_input_id, value):
        Value = request.env['fp.job.step.move.input.value']
        node_input = request.env['fusion.plating.process.node.input'].browse(node_input_id)
        payload = {
            'move_id': move.id,
            'node_input_id': node_input.id,
        }
        t = node_input.input_type
        if t in ('text', 'selection', 'time_hms'):
            payload['value_text'] = value or ''
        elif t in ('number', 'temperature', 'thickness', 'time_seconds',
                   'customer_wo'):
            payload['value_number'] = float(value) if value not in (None, '') else 0.0
        elif t == 'boolean' or t == 'pass_fail':
            payload['value_boolean'] = bool(value)
        elif t == 'date':
            payload['value_date'] = value or False
        elif t in ('signature', 'photo'):
            payload['value_attachment_id'] = int(value) if value else False
        elif t == 'location_picker':
            payload['value_text'] = value or ''
        else:
            payload['value_text'] = str(value) if value is not None else ''
        Value.create(payload)
  • Step 2: Wire into init

fusion_plating_shopfloor/controllers/__init__.py — add:

from . import move_controller
  • Step 3: Commit
git add fusion_plating_shopfloor/controllers/move_controller.py \
        fusion_plating_shopfloor/controllers/__init__.py
git commit -m "feat(sub12b): tablet Move Parts endpoints (preview + commit)

/fp/tablet/move_parts/preview returns dialog payload: qty_available,
from/to step + tank info, transition prompt list, blockers list.

/fp/tablet/move_parts/commit creates fp.job.step.move + captures
prompt values typed into fp.job.step.move.input.value (typed dispatch
on input_type → correct value_* column).

Hard blockers re-checked on commit (defence in depth — UI can lie
if state changes between preview/commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 9: Tablet controller — Move Rack + Rack Parts endpoints

Files:

  • Modify: fusion_plating_shopfloor/controllers/move_controller.py

  • Step 1: Append Move Rack endpoints

Add to the controller class:

    # ----------------------------------------------------- /move_rack/preview
    @http.route('/fp/tablet/move_rack/preview', type='jsonrpc', auth='user')
    def move_rack_preview(self, rack_id, to_step_id):
        Rack = request.env['fusion.plating.rack']
        Step = request.env['fp.job.step']
        rack = Rack.browse(rack_id)
        to_step = Step.browse(to_step_id)
        # All step batches currently on this rack
        batches = Step.search([('current_rack_id', '=', rack.id)])
        return {
            'ok': True,
            'rack': {
                'id': rack.id,
                'name': rack.name,
                'tag_ids': [
                    {'id': t.id, 'name': t.name, 'color': t.color}
                    for t in rack.tag_ids
                ],
            },
            'batches': [
                {
                    'step_id': s.id,
                    'qty': s.qty_done - s.qty_scrapped,
                    'part_number': s.job_id.product_id.default_code or '',
                    'wo_number': s.job_id.name,
                }
                for s in batches
            ],
            'to_step': self._step_payload(to_step),
        }

    # ------------------------------------------------------ /move_rack/commit
    @http.route('/fp/tablet/move_rack/commit', type='jsonrpc', auth='user')
    def move_rack_commit(self, rack_id, to_step_id, transfer_type='step',
                         to_tank_id=False):
        return self._safe(self._do_move_rack_commit,
            rack_id, to_step_id, transfer_type, to_tank_id,
        )

    def _do_move_rack_commit(self, rack_id, to_step_id, transfer_type, to_tank_id):
        Rack = request.env['fusion.plating.rack']
        Step = request.env['fp.job.step']
        Move = request.env['fp.job.step.move']
        rack = Rack.browse(rack_id)
        to_step = Step.browse(to_step_id)

        moves = []
        for batch in Step.search([('current_rack_id', '=', rack.id)]):
            move = Move.create({
                'job_id': batch.job_id.id,
                'from_step_id': batch.id,
                'to_step_id': to_step.id,
                'transfer_type': transfer_type,
                'qty_moved': batch.qty_done - batch.qty_scrapped,
                'rack_id': rack.id,
                'to_tank_id': to_tank_id or False,
            })
            batch.qty_at_step_finish = batch.qty_done - batch.qty_scrapped
            to_step.qty_at_step_start = (
                (to_step.qty_at_step_start or 0)
                + batch.qty_done - batch.qty_scrapped
            )
            moves.append(move.id)

        rack.state = 'in_use'
        return {'move_ids': moves, 'count': len(moves)}

    # ----------------------------------------------------- /rack_parts/commit
    @http.route('/fp/tablet/rack_parts/commit', type='jsonrpc', auth='user')
    def rack_parts_commit(self, from_step_id, rack_id, qty=None):
        return self._safe(self._do_rack_parts_commit,
            from_step_id, rack_id, qty,
        )

    def _do_rack_parts_commit(self, from_step_id, rack_id, qty):
        Rack = request.env['fusion.plating.rack']
        Step = request.env['fp.job.step']
        rack = Rack.browse(rack_id)
        if rack.state not in ('empty', 'loading'):
            raise UserError(_("Rack %s is not available (state=%s).") %
                            (rack.name, rack.state))
        from_step = Step.browse(from_step_id)
        from_step.current_rack_id = rack.id
        rack.state = 'loaded'
        return {'rack_id': rack.id, 'rack_name': rack.name}

    # --------------------------------------------------------- /rack/list_empty
    @http.route('/fp/tablet/rack/list_empty', type='jsonrpc', auth='user')
    def rack_list_empty(self, query=''):
        Rack = request.env['fusion.plating.rack']
        domain = [('state', '=', 'empty')]
        if query:
            domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
        records = Rack.search(domain, limit=50)
        return {
            'ok': True,
            'racks': [
                {'id': r.id, 'name': r.name, 'code': r.code}
                for r in records
            ],
        }

    # ------------------------------------------------------- /rack/scan_qr
    @http.route('/fp/tablet/rack/scan_qr', type='jsonrpc', auth='user')
    def rack_scan_qr(self, qr_code):
        # Token format: FP-RACK:<code>
        prefix = 'FP-RACK:'
        if not qr_code.startswith(prefix):
            return {'ok': False, 'error': _("Not a rack QR code.")}
        code = qr_code[len(prefix):]
        rack = request.env['fusion.plating.rack'].search(
            [('code', '=', code)], limit=1,
        )
        if not rack:
            return {'ok': False, 'error': _("Rack %s not found.") % code}
        return {'ok': True, 'rack_id': rack.id, 'rack_name': rack.name,
                'state': rack.state}
  • Step 2: Commit
git add fusion_plating_shopfloor/controllers/move_controller.py
git commit -m "feat(sub12b): Move Rack + Rack Parts + scan_qr endpoints

/fp/tablet/move_rack/preview  → all batches on rack + to_step info
/fp/tablet/move_rack/commit   → atomic multi-batch move (one
                                fp.job.step.move row per batch, all
                                tagged with the same rack_id)
/fp/tablet/rack_parts/commit  → assign step → rack, flip rack state
/fp/tablet/rack/list_empty    → empty-rack picker dropdown
/fp/tablet/rack/scan_qr       → resolve FP-RACK:<code> to a rack id

All write endpoints _safe-wrapped → tablet flashes the error string
instead of crashing the OWL component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: Tablet controller — Stop Timer endpoints

Files:

  • Modify: fusion_plating_shopfloor/controllers/move_controller.py

  • Step 1: Append timer endpoints

    # ====================================================================
    # Persistent labor timer endpoints (extends fp.job.step.timelog)
    # ====================================================================

    @http.route('/fp/tablet/labor_timer/start', type='jsonrpc', auth='user')
    def labor_timer_start(self, step_id):
        return self._safe(self._do_labor_timer_start, step_id)

    def _do_labor_timer_start(self, step_id):
        Tl = request.env['fp.job.step.timelog']
        # End any existing running timer for this user
        running = Tl.search([
            ('user_id', '=', request.env.uid),
            ('state', '=', 'running'),
        ])
        for r in running:
            r.state = 'paused'
            r.last_paused_at = fields.Datetime.now()
        new = Tl.create({
            'step_id': step_id,
            'user_id': request.env.uid,
            'started_at': fields.Datetime.now(),
            'state': 'running',
        })
        return {'timer_id': new.id, 'started_at': new.started_at.isoformat()}

    @http.route('/fp/tablet/labor_timer/pause', type='jsonrpc', auth='user')
    def labor_timer_pause(self, timer_id):
        return self._safe(self._do_labor_timer_pause, timer_id)

    def _do_labor_timer_pause(self, timer_id):
        tl = request.env['fp.job.step.timelog'].browse(timer_id)
        if tl.state != 'running':
            raise UserError(_("Timer is not running."))
        tl.state = 'paused'
        tl.last_paused_at = fields.Datetime.now()
        return {'state': 'paused', 'accrued_seconds': tl.accrued_seconds}

    @http.route('/fp/tablet/labor_timer/resume', type='jsonrpc', auth='user')
    def labor_timer_resume(self, timer_id):
        return self._safe(self._do_labor_timer_resume, timer_id)

    def _do_labor_timer_resume(self, timer_id):
        tl = request.env['fp.job.step.timelog'].browse(timer_id)
        if tl.state != 'paused':
            raise UserError(_("Timer is not paused."))
        if tl.last_paused_at:
            paused_dur = (fields.Datetime.now() - tl.last_paused_at).total_seconds()
            tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
        tl.state = 'running'
        tl.last_paused_at = False
        return {'state': 'running', 'accrued_seconds': tl.accrued_seconds}

    @http.route('/fp/tablet/labor_timer/stop', type='jsonrpc', auth='user')
    def labor_timer_stop(self, timer_id):
        return self._safe(self._do_labor_timer_stop, timer_id)

    def _do_labor_timer_stop(self, timer_id):
        tl = request.env['fp.job.step.timelog'].browse(timer_id)
        if tl.state in ('stopped', 'reconciled'):
            raise UserError(_("Timer already stopped."))
        if tl.state == 'paused' and tl.last_paused_at:
            paused_dur = (fields.Datetime.now() - tl.last_paused_at).total_seconds()
            tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
            tl.last_paused_at = False
        tl.stopped_at = fields.Datetime.now()
        tl.state = 'stopped'
        # Default billed = accrued (operator can edit before reconcile)
        secs = tl.accrued_seconds
        tl.billed_hrs = secs // 3600
        tl.billed_min = (secs % 3600) // 60
        tl.billed_sec = secs % 60
        return {
            'state': 'stopped',
            'accrued_seconds': tl.accrued_seconds,
            'billed_hrs': tl.billed_hrs,
            'billed_min': tl.billed_min,
            'billed_sec': tl.billed_sec,
        }

    @http.route('/fp/tablet/labor_timer/reconcile', type='jsonrpc', auth='user')
    def labor_timer_reconcile(self, timer_id, billed_hrs=0, billed_min=0,
                              billed_sec=0, product_id=False, notes='',
                              start_new=False):
        return self._safe(self._do_labor_timer_reconcile,
            timer_id, billed_hrs, billed_min, billed_sec,
            product_id, notes, start_new,
        )

    def _do_labor_timer_reconcile(self, timer_id, billed_hrs, billed_min,
                                  billed_sec, product_id, notes, start_new):
        tl = request.env['fp.job.step.timelog'].browse(timer_id)
        tl.write({
            'billed_hrs': billed_hrs or 0,
            'billed_min': billed_min or 0,
            'billed_sec': billed_sec or 0,
            'product_id': product_id or False,
            'notes': notes or '',
            'state': 'reconciled',
        })
        result = {'state': 'reconciled', 'timer_id': tl.id}
        if start_new:
            new = self._do_labor_timer_start(tl.step_id.id)
            result['new_timer_id'] = new['timer_id']
        return result

    # Need fields import for fields.Datetime.now() above

Add from odoo import fields at the top of the file alongside the other imports.

  • Step 2: Commit
git add fusion_plating_shopfloor/controllers/move_controller.py
git commit -m "feat(sub12b): persistent labor timer endpoints

5 routes wrap fp.job.step.timelog state machine:
  /fp/tablet/labor_timer/start      → starts new running timer; auto-
                                       pauses any other running timer
                                       owned by this operator
  /fp/tablet/labor_timer/pause      → flip running → paused, stamp
                                       last_paused_at
  /fp/tablet/labor_timer/resume     → accumulate paused duration into
                                       total_paused_seconds, flip back
                                       to running
  /fp/tablet/labor_timer/stop       → pre-fill billed_* from accrued,
                                       state → stopped (Stop Timer
                                       dialog opens client-side)
  /fp/tablet/labor_timer/reconcile  → save edited billed_* + optional
                                       product_id, state → reconciled.
                                       If start_new=True, immediately
                                       start a new running timer on
                                       the same step.

Mirrors screen 10 — Cancel / Save / Save & Start New Timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 11: OWL — Move Parts dialog (component + template)

Files:

  • Create: fusion_plating_shopfloor/static/src/js/move_parts_dialog.js

  • Create: fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml

  • Step 1: Create the JS component

/** @odoo-module */

import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class FpMovePartsDialog extends Component {
    static template = "fusion_plating_shopfloor.FpMovePartsDialog";
    static components = { Dialog };
    static props = ["fromStepId", "toStepId", "onCommit", "close"];

    setup() {
        this.notification = useService("notification");
        this.state = useState({
            loading: true,
            qty: 0,
            qtyAvailable: 0,
            fromStep: {},
            toStep: {},
            transferType: "step",
            toTankId: false,
            toLocation: "global",
            customerWoCount: 0,
            transitionPrompts: [],
            promptValues: {},
            blockers: [],
            committing: false,
        });
        onWillStart(async () => {
            await this.loadPreview();
        });
    }

    async loadPreview() {
        this.state.loading = true;
        const data = await rpc("/fp/tablet/move_parts/preview", {
            from_step_id: this.props.fromStepId,
            to_step_id: this.props.toStepId,
        });
        if (!data.ok) {
            this.notification.add(data.error, { type: "danger" });
            this.props.close();
            return;
        }
        this.state.qtyAvailable = data.qty_available;
        this.state.qty = data.qty_available;
        this.state.fromStep = data.from_step;
        this.state.toStep = data.to_step;
        this.state.transitionPrompts = data.transition_prompts;
        this.state.blockers = data.blockers;
        this.state.toTankId = (data.to_step.tank_options[0] || {}).id || false;
        this.state.loading = false;
    }

    get hardBlocked() {
        return this.state.blockers.some((b) => b.severity === "hard");
    }

    get requiredPromptsBlank() {
        return this.state.transitionPrompts.some((p) => {
            return p.required && !this.state.promptValues[p.id];
        });
    }

    get canCommit() {
        return !this.state.committing &&
               !this.hardBlocked &&
               !this.requiredPromptsBlank &&
               this.state.qty > 0 &&
               this.state.qty <= this.state.qtyAvailable;
    }

    async onCommit() {
        if (!this.canCommit) return;
        this.state.committing = true;
        const result = await rpc("/fp/tablet/move_parts/commit", {
            from_step_id: this.props.fromStepId,
            to_step_id: this.props.toStepId,
            qty: this.state.qty,
            transfer_type: this.state.transferType,
            to_tank_id: this.state.toTankId || false,
            to_location: this.state.toLocation,
            customer_wo_count: this.state.customerWoCount,
            prompt_values: this.state.promptValues,
        });
        if (result.ok) {
            this.notification.add(
                _t("Move %s committed", result.move_name),
                { type: "success" },
            );
            if (this.props.onCommit) {
                this.props.onCommit(result);
            }
            this.props.close();
        } else {
            this.notification.add(result.error || _t("Move failed"),
                { type: "danger" });
            this.state.committing = false;
        }
    }

    onResolveBlocker(blocker) {
        // Sub 12b — resolution buttons. For Sub 12b initial scope, the
        // rack_required path is the only one with a UI handler; the
        // others log and inform the operator to escalate.
        if (blocker.resolve_action === "open_rack_parts_dialog") {
            // Parent component (tablet) listens for this and opens
            // the Rack Parts sub-dialog. Simplest path: fire a custom
            // event that the parent catches.
            const ev = new CustomEvent("fp-resolve-rack", {
                detail: { fromStepId: this.props.fromStepId },
            });
            window.dispatchEvent(ev);
            this.props.close();
        } else {
            this.notification.add(blocker.message, { type: "warning" });
        }
    }
}
  • Step 2: Create the OWL template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
    <Dialog title.translate="Move Parts" size="'lg'">
        <div class="o_fp_move_dialog" t-if="!state.loading">
            <div class="o_fp_move_field">
                <label>Part Count</label>
                <input type="number" t-model.number="state.qty"
                       t-att-min="1" t-att-max="state.qtyAvailable"/>
                <span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
            </div>
            <div class="o_fp_move_field">
                <label>From Node</label>
                <span t-esc="state.fromStep.name"/>
            </div>
            <div class="o_fp_move_field" t-if="state.fromStep.tank_name">
                <label>From Station</label>
                <span t-esc="state.fromStep.tank_name"/>
            </div>
            <div class="o_fp_move_field">
                <label>Transfer Type</label>
                <select t-model="state.transferType">
                    <option value="step">Step</option>
                    <option value="hold">Hold</option>
                    <option value="scrap">Scrap</option>
                    <option value="rework">Rework</option>
                    <option value="split">Split</option>
                    <option value="return">Return</option>
                </select>
            </div>
            <div class="o_fp_move_field">
                <label>To Node</label>
                <span t-esc="state.toStep.name"/>
            </div>
            <div class="o_fp_move_field"
                 t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
                <label>To Station</label>
                <select t-model.number="state.toTankId">
                    <t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
                        <option t-att-value="tk.id"><t t-esc="tk.name"/></option>
                    </t>
                </select>
            </div>
            <div class="o_fp_move_field">
                <label>To Location</label>
                <select t-model="state.toLocation">
                    <option value="global">Global</option>
                    <option value="quarantine">Quarantine</option>
                    <option value="staging_a">Staging A</option>
                    <option value="staging_b">Staging B</option>
                    <option value="shipping_dock">Shipping Dock</option>
                    <option value="scrap_bin">Scrap Bin</option>
                </select>
            </div>

            <div class="o_fp_compliance_prompts"
                 t-if="state.transitionPrompts.length">
                <h5>Compliance Prompts</h5>
                <div class="o_fp_prompt_row"
                     t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
                    <label>
                        <t t-esc="p.name"/>
                        <span t-if="p.required" class="text-danger">*</span>
                    </label>
                    <input t-if="['text','customer_wo','time_hms','location_picker','signature','photo'].includes(p.input_type)"
                           type="text" t-model="state.promptValues[p.id]"/>
                    <input t-elif="['number','temperature','thickness','time_seconds'].includes(p.input_type)"
                           type="number" t-model.number="state.promptValues[p.id]"/>
                    <input t-elif="p.input_type === 'boolean' or p.input_type === 'pass_fail'"
                           type="checkbox" t-model="state.promptValues[p.id]"/>
                    <input t-elif="p.input_type === 'date'"
                           type="datetime-local" t-model="state.promptValues[p.id]"/>
                    <select t-elif="p.input_type === 'selection'"
                            t-model="state.promptValues[p.id]">
                        <option value="">— Select —</option>
                        <t t-foreach="p.selection_options.split(',')"
                           t-as="opt" t-key="opt">
                            <option t-att-value="opt.trim()"><t t-esc="opt.trim()"/></option>
                        </t>
                    </select>
                    <span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
                </div>
            </div>

            <div class="o_fp_blockers" t-if="state.blockers.length">
                <h5>Blockers</h5>
                <div class="o_fp_blocker_row"
                     t-foreach="state.blockers" t-as="b" t-key="b_index"
                     t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
                    <span class="o_fp_blocker_icon"></span>
                    <span class="o_fp_blocker_msg" t-esc="b.message"/>
                    <button t-if="b.resolve_action"
                            class="btn btn-sm btn-warning"
                            t-on-click="() => this.onResolveBlocker(b)">
                        Resolve
                    </button>
                </div>
            </div>
        </div>

        <div t-if="state.loading">Loading…</div>

        <t t-set-slot="footer">
            <button class="btn btn-secondary" t-on-click="props.close">
                Cancel
            </button>
            <button class="btn btn-primary"
                    t-att-disabled="!canCommit"
                    t-on-click="onCommit">
                MOVE (<t t-esc="state.qty"/>)
            </button>
        </t>
    </Dialog>
</t>

</templates>
  • Step 3: Commit
git add fusion_plating_shopfloor/static/src/js/move_parts_dialog.js \
        fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml
git commit -m "feat(sub12b): OWL Move Parts dialog component + template

Mirrors Steelhead screens 1-3, 14-15. Loads preview on mount,
re-checks hard-blockers on commit. MOVE (n) button disabled when
hard-blocked OR required prompt blank — improvement over Steelhead's
silent disabled state.

Inline 'Resolve' button next to each blocker with a resolve_action.
For rack-required, fires a window CustomEvent ('fp-resolve-rack')
that the parent tablet catches to open the Rack Parts sub-dialog.

Typed input rendering by input_type — text/number/checkbox/select/
datetime, plus support for time_hms and signature/photo (text input
for now; full upload widget in Sub 12c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: OWL — Rack Parts sub-dialog

Files:

  • Create: fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js

  • Create: fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml

  • Step 1: Create the component

/** @odoo-module */

import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class FpRackPartsDialog extends Component {
    static template = "fusion_plating_shopfloor.FpRackPartsDialog";
    static components = { Dialog };
    static props = ["fromStepId", "qty", "onRacked", "close"];

    setup() {
        this.notification = useService("notification");
        this.state = useState({
            racks: [],
            search: "",
            selectedRackId: false,
            unit: "Count",
            amount: this.props.qty,
            saving: false,
        });
        onWillStart(async () => {
            await this.refreshRacks("");
        });
    }

    async refreshRacks(query) {
        const data = await rpc("/fp/tablet/rack/list_empty", { query });
        if (data.ok) {
            this.state.racks = data.racks;
        }
    }

    async onSearch(ev) {
        this.state.search = ev.target.value;
        await this.refreshRacks(this.state.search);
    }

    async onScan() {
        const code = window.prompt(_t("Scan or type FP-RACK:<code>:"));
        if (!code) return;
        const data = await rpc("/fp/tablet/rack/scan_qr", { qr_code: code });
        if (data.ok) {
            this.state.selectedRackId = data.rack_id;
            this.notification.add(
                _t("Selected %s", data.rack_name), { type: "success" });
        } else {
            this.notification.add(data.error, { type: "danger" });
        }
    }

    async onSave(printAfter) {
        if (!this.state.selectedRackId) return;
        this.state.saving = true;
        const result = await rpc("/fp/tablet/rack_parts/commit", {
            from_step_id: this.props.fromStepId,
            rack_id: this.state.selectedRackId,
            qty: this.state.amount,
        });
        if (result.ok) {
            this.notification.add(
                _t("Racked onto %s", result.rack_name),
                { type: "success" });
            if (this.props.onRacked) {
                this.props.onRacked(result);
            }
            this.props.close();
            if (printAfter) {
                window.open(`/web/report/pdf/fp.rack.travel/${this.state.selectedRackId}`, "_blank");
            }
        } else {
            this.notification.add(result.error, { type: "danger" });
            this.state.saving = false;
        }
    }
}
  • Step 2: Create the template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="fusion_plating_shopfloor.FpRackPartsDialog">
    <Dialog title.translate="Rack Parts" size="'md'">
        <div class="o_fp_rack_dialog">
            <div class="o_fp_move_field">
                <label>To Rack</label>
                <input type="text" placeholder="Search racks…"
                       t-on-input="onSearch" t-att-value="state.search"/>
                <button class="btn btn-sm btn-secondary"
                        t-on-click="onScan">
                    QR Scan
                </button>
                <select t-model.number="state.selectedRackId">
                    <option value="">— Select —</option>
                    <t t-foreach="state.racks" t-as="r" t-key="r.id">
                        <option t-att-value="r.id">
                            <t t-esc="r.name"/> (<t t-esc="r.code"/>)
                        </option>
                    </t>
                </select>
            </div>
            <div class="o_fp_move_field">
                <label>Unit</label>
                <select t-model="state.unit">
                    <option value="Count">Count</option>
                    <option value="Pieces">Pieces</option>
                    <option value="Lbs">Lbs</option>
                    <option value="Kg">Kg</option>
                </select>
            </div>
            <div class="o_fp_move_field">
                <label>Amount</label>
                <input type="number" t-model.number="state.amount"/>
            </div>
        </div>

        <t t-set-slot="footer">
            <button class="btn btn-secondary" t-on-click="props.close">
                Cancel
            </button>
            <button class="btn btn-primary"
                    t-att-disabled="!state.selectedRackId or state.saving"
                    t-on-click="() => this.onSave(false)">
                Save
            </button>
            <button class="btn btn-warning"
                    t-att-disabled="!state.selectedRackId or state.saving"
                    t-on-click="() => this.onSave(true)">
                Save + Print
            </button>
        </t>
    </Dialog>
</t>

</templates>
  • Step 3: Commit
git add fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js \
        fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml
git commit -m "feat(sub12b): OWL Rack Parts sub-dialog (mirrors screens 7-8)

Searchable empty-rack picker, QR-scan input, Unit (Count/Pieces/Lbs/
Kg) + Amount fields. Save commits the racking; Save + Print also
opens the rack travel ticket PDF in a new tab.

Note: the rack travel report (/web/report/pdf/fp.rack.travel/<id>)
is wired here but the actual report template lands in Sub 12c. For
Sub 12b shipping, Save + Print works once Sub 12c lands; until then
operators use plain Save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: OWL — Move Rack dialog + Stop Timer dialog

Files:

  • Create: fusion_plating_shopfloor/static/src/js/move_rack_dialog.js

  • Create: fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml

  • Create: fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js

  • Create: fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml

  • Step 1: Move Rack JS — same shape as Move Parts but no transition prompts (rack moves don't show per-step prompts; they're rack-level). Render rack name in title, parts list (read-only), Type + To Node + To Station picker, billed labor block per batch.

/** @odoo-module */

import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class FpMoveRackDialog extends Component {
    static template = "fusion_plating_shopfloor.FpMoveRackDialog";
    static components = { Dialog };
    static props = ["rackId", "toStepId", "onCommit", "close"];

    setup() {
        this.notification = useService("notification");
        this.state = useState({
            loading: true,
            rack: {},
            batches: [],
            toStep: {},
            transferType: "step",
            toTankId: false,
            saving: false,
        });
        onWillStart(async () => {
            const data = await rpc("/fp/tablet/move_rack/preview", {
                rack_id: this.props.rackId,
                to_step_id: this.props.toStepId,
            });
            if (!data.ok) {
                this.notification.add(data.error, { type: "danger" });
                this.props.close();
                return;
            }
            this.state.rack = data.rack;
            this.state.batches = data.batches;
            this.state.toStep = data.to_step;
            this.state.toTankId = (data.to_step.tank_options[0] || {}).id || false;
            this.state.loading = false;
        });
    }

    async onSave() {
        this.state.saving = true;
        const result = await rpc("/fp/tablet/move_rack/commit", {
            rack_id: this.props.rackId,
            to_step_id: this.props.toStepId,
            transfer_type: this.state.transferType,
            to_tank_id: this.state.toTankId || false,
        });
        if (result.ok) {
            this.notification.add(
                _t("Moved %s batches", result.count),
                { type: "success" });
            if (this.props.onCommit) {
                this.props.onCommit(result);
            }
            this.props.close();
        } else {
            this.notification.add(result.error, { type: "danger" });
            this.state.saving = false;
        }
    }
}

fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml:

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="fusion_plating_shopfloor.FpMoveRackDialog">
    <Dialog t-att-title="'Move Rack: ' + (state.rack.name || '')" size="'lg'">
        <div class="o_fp_move_dialog" t-if="!state.loading">
            <div class="o_fp_move_field">
                <label>Rack Tags</label>
                <span t-foreach="state.rack.tag_ids" t-as="t" t-key="t.id"
                      class="o_fp_rack_tag_chip"
                      t-att-data-color="t.color">
                    <t t-esc="t.name"/>
                </span>
            </div>
            <div class="o_fp_move_field">
                <label>Parts</label>
                <ul>
                    <t t-foreach="state.batches" t-as="b" t-key="b.step_id">
                        <li>
                            <t t-esc="b.qty"/> <t t-esc="b.part_number"/>
                            on WO <t t-esc="b.wo_number"/>
                        </li>
                    </t>
                </ul>
            </div>
            <div class="o_fp_move_field">
                <label>Type</label>
                <select t-model="state.transferType">
                    <option value="step">Step</option>
                    <option value="hold">Hold</option>
                    <option value="rework">Rework</option>
                </select>
            </div>
            <div class="o_fp_move_field">
                <label>To Node</label>
                <span t-esc="state.toStep.name"/>
            </div>
            <div class="o_fp_move_field"
                 t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
                <label>To Station</label>
                <select t-model.number="state.toTankId">
                    <t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
                        <option t-att-value="tk.id"><t t-esc="tk.name"/></option>
                    </t>
                </select>
            </div>
        </div>
        <div t-if="state.loading">Loading…</div>

        <t t-set-slot="footer">
            <button class="btn btn-secondary" t-on-click="props.close">
                Cancel
            </button>
            <button class="btn btn-primary"
                    t-att-disabled="state.saving"
                    t-on-click="onSave">
                Save
            </button>
        </t>
    </Dialog>
</t>

</templates>
  • Step 2: Stop Timer JS
/** @odoo-module */

import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class FpStopTimerDialog extends Component {
    static template = "fusion_plating_shopfloor.FpStopTimerDialog";
    static components = { Dialog };
    static props = ["timerId", "onReconciled", "close"];

    setup() {
        this.notification = useService("notification");
        this.state = useState({
            loading: true,
            accruedSeconds: 0,
            billedHrs: 0,
            billedMin: 0,
            billedSec: 0,
            productId: false,
            notes: "",
            saving: false,
        });
        onWillStart(async () => {
            // Stop the timer (transitions running/paused → stopped).
            const data = await rpc("/fp/tablet/labor_timer/stop",
                { timer_id: this.props.timerId });
            if (data.ok) {
                this.state.accruedSeconds = data.accrued_seconds;
                this.state.billedHrs = data.billed_hrs;
                this.state.billedMin = data.billed_min;
                this.state.billedSec = data.billed_sec;
                this.state.loading = false;
            } else {
                this.notification.add(data.error, { type: "danger" });
                this.props.close();
            }
        });
    }

    get billedPct() {
        const total = this.state.billedHrs * 3600
                    + this.state.billedMin * 60
                    + this.state.billedSec;
        if (!this.state.accruedSeconds) return 0;
        return Math.round(100 * total / this.state.accruedSeconds);
    }

    async commit(startNew) {
        this.state.saving = true;
        const result = await rpc("/fp/tablet/labor_timer/reconcile", {
            timer_id: this.props.timerId,
            billed_hrs: this.state.billedHrs,
            billed_min: this.state.billedMin,
            billed_sec: this.state.billedSec,
            product_id: this.state.productId || false,
            notes: this.state.notes,
            start_new: startNew,
        });
        if (result.ok) {
            this.notification.add(_t("Timer reconciled"), { type: "success" });
            if (this.props.onReconciled) {
                this.props.onReconciled(result);
            }
            this.props.close();
        } else {
            this.notification.add(result.error, { type: "danger" });
            this.state.saving = false;
        }
    }

    onSave() { return this.commit(false); }
    onSaveAndStartNew() { return this.commit(true); }
}

fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml:

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="fusion_plating_shopfloor.FpStopTimerDialog">
    <Dialog title.translate="Stop User Labor Timer" size="'md'">
        <div class="o_fp_timer_dialog" t-if="!state.loading">
            <div class="o_fp_timer_summary">
                Accrued: <t t-esc="state.accruedSeconds"/> sec
                · Billed: <t t-esc="billedPct"/>%
            </div>
            <div class="o_fp_move_field">
                <label>Billed Time</label>
                <input type="number" t-model.number="state.billedHrs"
                       min="0"/> hrs
                <input type="number" t-model.number="state.billedMin"
                       min="0" max="59"/> min
                <input type="number" t-model.number="state.billedSec"
                       min="0" max="59"/> sec
            </div>
            <div class="o_fp_move_field">
                <label>Notes</label>
                <textarea t-model="state.notes" rows="2"/>
            </div>
        </div>
        <div t-if="state.loading">Loading…</div>

        <t t-set-slot="footer">
            <button class="btn btn-secondary" t-on-click="props.close">
                Cancel
            </button>
            <button class="btn btn-primary"
                    t-att-disabled="state.saving"
                    t-on-click="onSave">
                Save
            </button>
            <button class="btn btn-warning"
                    t-att-disabled="state.saving"
                    t-on-click="onSaveAndStartNew">
                Save &amp; Start New Timer
            </button>
        </t>
    </Dialog>
</t>

</templates>
  • Step 3: Commit
git add fusion_plating_shopfloor/static/src/js/move_rack_dialog.js \
        fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml \
        fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js \
        fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml
git commit -m "feat(sub12b): Move Rack + Stop Timer OWL dialogs

Move Rack: rack name in title, tag chips, batches list (read-only),
Type + To Node + To Station picker, atomic Save commits all batches.

Stop Timer: opens with state already at 'stopped' (server flipped on
load), pre-fills billed_* from accrued. Operator edits → Save (state
→ reconciled). Save & Start New Timer chains into a fresh timer for
the same step (mirrors screen 10's right-most button).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: Shared SCSS for the dialogs

Files:

  • Create: fusion_plating_shopfloor/static/src/scss/move_dialogs.scss

  • Step 1: Create the SCSS

// Sub 12b — shared SCSS for Move Parts / Move Rack / Rack Parts /
// Stop Timer dialogs. Tokens follow fp-shopfloor pattern with
// dark-mode SCSS @if branch.

$o-webclient-color-scheme: bright !default;

$_fp_md_card_hex:    #ffffff;
$_fp_md_border_hex:  #d8dadd;
$_fp_md_accent_hex:  #2e7d6b;
$_fp_md_muted_hex:   #6b7280;
$_fp_md_warn_hex:    #fff3cd;
$_fp_md_danger_hex:  #f8d7da;

@if $o-webclient-color-scheme == dark {
    $_fp_md_card_hex:   #22262d !global;
    $_fp_md_border_hex: #3a3f47 !global;
    $_fp_md_warn_hex:   #4a3a1a !global;
    $_fp_md_danger_hex: #4a1f1f !global;
}

$fp-md-card:   var(--fp-card-bg, #{$_fp_md_card_hex});
$fp-md-border: var(--fp-border-color, #{$_fp_md_border_hex});
$fp-md-accent: var(--fp-accent, #{$_fp_md_accent_hex});
$fp-md-muted:  var(--fp-muted, #{$_fp_md_muted_hex});
$fp-md-warn:   var(--fp-warn-bg, #{$_fp_md_warn_hex});
$fp-md-danger: var(--fp-danger-bg, #{$_fp_md_danger_hex});

.o_fp_move_dialog,
.o_fp_rack_dialog,
.o_fp_timer_dialog {
    display: flex;
    flex-direction: column;
    gap: .75rem;

    .o_fp_move_field {
        display: grid;
        grid-template-columns: 9rem 1fr auto;
        align-items: center;
        gap: .5rem;

        label {
            font-weight: 500;
            margin: 0;
        }

        input, select, textarea {
            padding: .375rem .5rem;
            border: 1px solid $fp-md-border;
            border-radius: 4px;
            background: $fp-md-card;
        }
    }

    .o_fp_compliance_prompts,
    .o_fp_blockers {
        margin-top: .5rem;
        padding: .75rem;
        border: 1px solid $fp-md-border;
        border-radius: 4px;

        h5 {
            margin: 0 0 .5rem 0;
            color: $fp-md-accent;
            font-size: .9rem;
        }
    }

    .o_fp_blocker_row {
        display: flex;
        align-items: center;
        gap: .5rem;
        padding: .5rem;
        margin-bottom: .25rem;
        border-radius: 4px;

        &.o_fp_blocker_soft {
            background: $fp-md-warn;
        }
        &.o_fp_blocker_hard {
            background: $fp-md-danger;
        }

        .o_fp_blocker_icon {
            font-size: 1.25rem;
        }
        .o_fp_blocker_msg {
            flex: 1;
        }
    }
}

.o_fp_rack_tag_chip {
    display: inline-block;
    padding: .125rem .5rem;
    margin-right: .25rem;
    border-radius: 999px;
    font-size: .75rem;
    background: $fp-md-card;
    border: 1px solid $fp-md-border;
}

.o_fp_timer_summary {
    padding: .5rem;
    background: var(--fp-page-bg, #f3f4f6);
    border-radius: 4px;
    color: $fp-md-muted;
    font-size: .875rem;
}
  • Step 2: Commit
git add fusion_plating_shopfloor/static/src/scss/move_dialogs.scss
git commit -m "feat(sub12b): shared SCSS for Move/Rack/Timer dialogs

3-column grid layout for field rows (label / input / hint).
Compliance prompts + Blockers blocks have their own backgrounds.
Soft blockers amber, hard blockers red — matches the spec's protocol.

Token pattern + dark-mode @if branch (CLAUDE.md rule).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 15: Wire dialogs into the tablet + plant-overview UIs

Files:

  • Modify: fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js

  • Modify: fusion_plating_shopfloor/static/src/js/plant_overview.js

  • Modify: fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml

  • Modify: fusion_plating_shopfloor/static/src/xml/plant_overview.xml

  • Step 1: Read existing tablet JS to find where to add the buttons

grep -n "openMoveDialog\|move_parts\|move_rack\|onTimer\|stop_wo" fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js | head
  • Step 2: Add Move Parts / Stop Timer button handlers to shopfloor_tablet.js

In the FpShopfloorTablet component (or whatever the existing class is called), add at the top alongside other imports:

import { FpMovePartsDialog } from "./move_parts_dialog";
import { FpStopTimerDialog } from "./stop_timer_dialog";
import { FpRackPartsDialog } from "./rack_parts_dialog";

Inside setup(), listen for the rack-resolution custom event:

        window.addEventListener("fp-resolve-rack", (ev) => {
            this.dialog.add(FpRackPartsDialog, {
                fromStepId: ev.detail.fromStepId,
                qty: 0,
                onRacked: () => this.refreshTablet(),
            });
        });

Add new methods:

    openMovePartsDialog(fromStepId, toStepId) {
        this.dialog.add(FpMovePartsDialog, {
            fromStepId, toStepId,
            onCommit: () => this.refreshTablet(),
        });
    }

    openStopTimerDialog(timerId) {
        this.dialog.add(FpStopTimerDialog, {
            timerId,
            onReconciled: () => this.refreshTablet(),
        });
    }

(this.refreshTablet() should be the existing reload method — read the file to find what it's called and adjust.)

  • Step 3: Add Move Rack handler to plant_overview.js
import { FpMoveRackDialog } from "./move_rack_dialog";
    openMoveRackDialog(rackId, toStepId) {
        this.dialog.add(FpMoveRackDialog, {
            rackId, toStepId,
            onCommit: () => this.reload(),
        });
    }
  • Step 4: Wire the buttons in the XML templates

In shopfloor_tablet.xml, find the existing per-step row and add (next to existing Mark Done / Pause buttons):

<button class="btn btn-sm btn-primary"
        t-on-click="() => this.openMovePartsDialog(step.id, step.next_step_id)"
        t-att-disabled="step.is_racked"
        t-att-title="step.is_racked ? 'Racked — use Move Rack' : ''">
    Move Parts
</button>

For the timer pause button (existing), wrap to call openStopTimerDialog instead of the current direct-pause behavior:

<button class="btn btn-sm btn-warning"
        t-on-click="() => this.openStopTimerDialog(step.active_timer_id)"
        t-if="step.active_timer_id">
    Stop Timer
</button>

In plant_overview.xml, find the rack section (or add one if there's no Racks pane yet — see Task 16). Add MOVE RACK button per row:

<button class="btn btn-sm btn-danger"
        t-on-click="() => this.openMoveRackDialog(rack.id, rack.next_step_id)">
    MOVE RACK
</button>
  • Step 5: Commit
git add fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js \
        fusion_plating_shopfloor/static/src/js/plant_overview.js \
        fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml \
        fusion_plating_shopfloor/static/src/xml/plant_overview.xml
git commit -m "feat(sub12b): wire Move/Rack/Timer dialogs into tablet + plant overview

Tablet: Move Parts button per step row (greyed when is_racked).
Listens for the 'fp-resolve-rack' custom event from inside Move Parts
to spawn Rack Parts sub-dialog. Stop Timer button replaces the legacy
direct-pause action.

Plant overview: MOVE RACK button per rack row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 16: Plant overview — 2-pane layout (Racks / Parts)

Files:

  • Modify: fusion_plating_shopfloor/static/src/js/plant_overview.js

  • Modify: fusion_plating_shopfloor/static/src/xml/plant_overview.xml

  • Modify: fusion_plating_shopfloor/controllers/shopfloor_controller.py

  • Step 1: Extend the existing /fp/shopfloor/plant_overview endpoint to return racks alongside batches:

        # In the existing plant_overview method's return payload, add:
        racks = request.env['fusion.plating.rack'].search([
            ('state', 'in', ('loaded', 'in_use', 'awaiting_unrack')),
        ])
        result['racks'] = [
            {
                'id': r.id,
                'name': r.name,
                'state': r.state,
                'tag_ids': [{'id': t.id, 'name': t.name, 'color': t.color}
                            for t in r.tag_ids],
                'current_part_count': r.current_part_count,
                'current_node_name': r.current_job_step_id.name or '',
                'current_tank_code': r.current_tank_id.code or '',
            }
            for r in racks
        ]
  • Step 2: Render the Racks pane in plant_overview.xml

Add ABOVE the existing batches/parts pane:

<div class="o_fp_racks_pane" t-if="state.racks and state.racks.length">
    <div class="o_fp_racks_header">
        <h3>Racks</h3>
        <button class="btn btn-sm btn-warning"
                t-on-click="onUnrackMultiple">
            UNRACK MULTIPLE
        </button>
    </div>
    <div class="o_fp_rack_row"
         t-foreach="state.racks" t-as="rack" t-key="rack.id">
        <span class="o_fp_rack_name"><t t-esc="rack.name"/></span>
        <span class="o_fp_rack_count"><t t-esc="rack.current_part_count"/> parts</span>
        <span class="o_fp_rack_breadcrumb">
            <t t-esc="rack.current_node_name"/>
            <t t-if="rack.current_tank_code"> / <t t-esc="rack.current_tank_code"/></t>
        </span>
        <span t-foreach="rack.tag_ids" t-as="tag" t-key="tag.id"
              class="o_fp_rack_tag_chip">
            <t t-esc="tag.name"/>
        </span>
        <button class="btn btn-sm btn-primary"
                t-on-click="() => this.openMoveRackDialog(rack.id, rack.next_step_id)">
            MOVE RACK
        </button>
    </div>
</div>
  • Step 3: Add onUnrackMultiple method
    async onUnrackMultiple() {
        const selected = this.state.racks.filter((r) => r.selected);
        if (!selected.length) {
            this.notification.add(_t("Select racks to unrack first."),
                { type: "warning" });
            return;
        }
        // Bulk unrack: simply flip rack state and clear current_rack_id
        // on linked steps. This is best-effort; manager review of the
        // unrack chain happens via the Move Log.
        for (const r of selected) {
            await rpc("/fp/tablet/rack_parts/commit", {
                from_step_id: false,  // skip step-side update
                rack_id: r.id,
                qty: 0,
            });
        }
        await this.reload();
    }
  • Step 4: Commit
git add fusion_plating_shopfloor/controllers/shopfloor_controller.py \
        fusion_plating_shopfloor/static/src/js/plant_overview.js \
        fusion_plating_shopfloor/static/src/xml/plant_overview.xml
git commit -m "feat(sub12b): plant overview — Racks pane + UNRACK MULTIPLE

Top pane shows all racks in (loaded / in_use / awaiting_unrack)
state with tag chips, part count, current node breadcrumb, and a
MOVE RACK primary button per row. UNRACK MULTIPLE bulk action
clears selected racks (operator-driven; movements are still logged
via fp.job.step.move).

Plant-overview controller payload extended to include racks list
alongside the existing parts/batches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 17: Manager-bypass context flags

Files:

  • Modify: fusion_plating_shopfloor/controllers/move_controller.py

  • Step 1: Add bypass-flag handling

In _blockers_for_move, at the top:

        ctx = request.env.context
        # Manager-bypass flags (per CLAUDE.md cross-cutting protocol)
        skip_predecessor = ctx.get('fp_skip_predecessor_check') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')
        skip_rack = ctx.get('fp_skip_rack_assignment') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')
        skip_transition = ctx.get('fp_skip_transition_form') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')

Then guard the corresponding blockers behind these flags.

In _do_move_parts_commit, when a manager-bypass triggers, post to chatter:

        if skip_predecessor or skip_rack or skip_transition:
            move.message_post(body=_(
                "Manager bypass activated by %s — flags: predecessor=%s, "
                "rack=%s, transition=%s"
            ) % (request.env.user.name, skip_predecessor, skip_rack, skip_transition))
  • Step 2: Commit
git add fusion_plating_shopfloor/controllers/move_controller.py
git commit -m "feat(sub12b): manager-bypass context flags

Three new flags consistent with the existing fp_skip_* protocol
(CLAUDE.md cross-cutting):
  fp_skip_predecessor_check  → bypass S14 lock
  fp_skip_rack_assignment    → bypass requires_rack_assignment
  fp_skip_transition_form    → bypass required transition prompts

Manager-only (group check). All bypasses post to chatter on the
move record naming the user + which flags fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 18: Deploy to entech + smoke test + push

Files:

  • (none — deployment + manual verification)

  • Step 1: Tar and ship all Sub 12b files

tar -cf - \
  fusion_plating/__init__.py \
  fusion_plating/__manifest__.py \
  fusion_plating/models/__init__.py \
  fusion_plating/models/fp_rack.py \
  fusion_plating/models/fp_rack_tag.py \
  fusion_plating/models/fp_job.py \
  fusion_plating/models/fp_job_step.py \
  fusion_plating/models/fp_job_step_timelog.py \
  fusion_plating/models/fp_job_step_move.py \
  fusion_plating/security/ir.model.access.csv \
  fusion_plating/data/fp_sequence_data.xml \
  fusion_plating/views/fp_rack_views.xml \
  fusion_plating/views/fp_rack_tag_views.xml \
  fusion_plating/views/fp_job_step_move_views.xml \
  fusion_plating_shopfloor/__manifest__.py \
  fusion_plating_shopfloor/controllers/__init__.py \
  fusion_plating_shopfloor/controllers/move_controller.py \
  fusion_plating_shopfloor/controllers/shopfloor_controller.py \
  fusion_plating_shopfloor/static/src/js/move_parts_dialog.js \
  fusion_plating_shopfloor/static/src/js/move_rack_dialog.js \
  fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js \
  fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js \
  fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js \
  fusion_plating_shopfloor/static/src/js/plant_overview.js \
  fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml \
  fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml \
  fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml \
  fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml \
  fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml \
  fusion_plating_shopfloor/static/src/xml/plant_overview.xml \
  fusion_plating_shopfloor/static/src/scss/move_dialogs.scss \
  | ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar -xf -'"
  • Step 2: Update both modules
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
  su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
    -u fusion_plating,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -25 && \
  systemctl start odoo'"

Expected: clean upgrade (233 modules loaded, no errors).

  • Step 3: Clear asset cache
ssh pve-worker5 "pct exec 111 -- bash -c \"su - postgres -c 'psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\\''/web/assets/%'\\'';\\\"'\""
  • Step 4: Manual smoke test on tablet (browser, real entech data)
  1. Open tablet station (Plating → Shop Floor → Tablet Station). Confirm the per-step row now shows a Move Parts button.
  2. Tap Move Parts on an in-flight step → dialog opens, system fields pre-filled.
  3. If the destination step has any author-defined transition prompts, confirm the Compliance Prompts section renders them. Try tapping MOVE with a required prompt blank → button is disabled, hover tooltip explains why.
  4. Fill prompts, tap MOVE → notification "Move FP/MOVE/2026/00001 committed".
  5. Verify Plating → Move Log shows the new row.
  6. Move parts to a step where the destination has requires_rack_assignment=True (set this on a step template via Sub 12a's library form first if not already). Confirm amber blocker + RACK PARTS button.
  7. Tap RACK PARTS → sub-dialog → pick rack → Save → blocker clears, MOVE re-enables.
  8. Confirm tablet now shows Move Parts button greyed out for the racked batch.
  9. On Plant Overview, confirm Racks pane appears with the loaded rack. Tap MOVE RACK → atomic multi-batch move.
  10. Tap timer pause on a running step → Stop Timer dialog opens. Edit billed time, tap Save → state moves to reconciled. Try Save & Start New Timer to verify chaining works.
  11. Run battle test bt_s2_* (existing) → confirm timer + step transitions still work end-to-end.
  • Step 5: Update CLAUDE.md sub-project status table

Mark Sub 12b as Shipped 2026-MM-DD with the merge commit SHA.

  • Step 6: Commit + push
git add CLAUDE.md
git commit -m "docs(sub12b): mark Sub 12b as shipped on entech"
git push origin main

Self-Review

Spec coverage check

Spec section Task
5.2 fp.rack Task 3 (extends existing)
5.2 fp.rack.tag Task 2
5.2 fp.job.step.move + child input value Tasks 4, 5
5.2 fp.labor.timer Task 7 (folded into existing fp.job.step.timelog with state machine)
5.2 fp.job.step extensions Task 6
5.2 fp.job extensions Task 6
5.3 Move Parts dialog Tasks 8, 11
5.4 Rack Parts sub-dialog Tasks 9, 12
5.5 Move Rack dialog Tasks 9, 13
5.6 Stop Timer dialog Tasks 10, 13
5.7 Tablet runtime guards Task 15
5.7 Plant overview 2-pane Task 16
5.8 Backend controller endpoints Tasks 8, 9, 10
5.9 Migration / install Tasks 1, 2 (rack tag seed)
5.10 Verification Task 18
7.1 Manager-bypass flags Task 17

All spec sections covered.

Type / signature consistency

  • _blockers_for_move(from_step, to_step, qty) defined Task 8, called by move_parts_preview (Task 8) and _do_move_parts_commit (Task 8) with same signature. ✓
  • _safe(method, *args, **kwargs) defined Task 8, used by Tasks 8, 9, 10. ✓
  • _step_payload defined Task 8, used by Move Parts preview + Move Rack preview (Task 9). ✓
  • _capture_prompt_value(move, node_input_id, value) defined Task 8, called by _do_move_parts_commit (Task 8). ✓
  • fp.job.step.timelog.state defined Task 7, used by Task 10's start/pause/resume/stop/reconcile endpoints + Task 6's active_timer_ids filtered O2M. ✓
  • Custom event name fp-resolve-rack defined in Move Parts dialog (Task 11), listened to in tablet (Task 15). ✓
  • Manager-bypass flag names fp_skip_* defined Task 17, consistent with existing flags in CLAUDE.md.

Placeholder scan

No "TBD" / "TODO" / "implement later" / "fill in details" found.

One acknowledged gap: Task 12's Save + Print in Rack Parts dialog references /web/report/pdf/fp.rack.travel/<id> — the actual report ships in Sub 12c. Until then, Save + Print produces a 404. Documented in the task body.


Plan complete. 18 tasks, ~3-4 days end-to-end.