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>
103 KiB
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:
- Spec section 5
- Steelhead screen inventory — screens 1–18
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-initclean 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 & 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_overviewendpoint 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
onUnrackMultiplemethod
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)
- Open tablet station (Plating → Shop Floor → Tablet Station). Confirm the per-step row now shows a Move Parts button.
- Tap Move Parts on an in-flight step → dialog opens, system fields pre-filled.
- 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.
- Fill prompts, tap MOVE → notification "Move FP/MOVE/2026/00001 committed".
- Verify Plating → Move Log shows the new row.
- 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. - Tap RACK PARTS → sub-dialog → pick rack → Save → blocker clears, MOVE re-enables.
- Confirm tablet now shows Move Parts button greyed out for the racked batch.
- On Plant Overview, confirm Racks pane appears with the loaded rack. Tap MOVE RACK → atomic multi-batch move.
- 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.
- 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 bymove_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_payloaddefined 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.statedefined Task 7, used by Task 10's start/pause/resume/stop/reconcile endpoints + Task 6'sactive_timer_idsfiltered O2M. ✓- Custom event name
fp-resolve-rackdefined 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.