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

2773 lines
103 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../specs/2026-04-27-sub12-simple-recipe-editor-design.md) section 5
- [Steelhead screen inventory](../specs/2026-04-27-simple-recipe-editor-steelhead-screens.md) — screens 118
**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**:
```bash
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**
```python
# 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`):
```python
'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:
```bash
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'`:
```python
'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**
```bash
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`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from 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
<?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:
```python
# 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`:
```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:
```python
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**
```bash
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**
```bash
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:
```python
# ===== 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:
```xml
<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**
```bash
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):
```xml
<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`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from 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:
```python
from . import fp_job_step_move
```
- [ ] **Step 4: ACL rows**
Append to `fusion_plating/security/ir.model.access.csv`:
```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**
```bash
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
<?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**
```bash
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**
```bash
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:
```python
# ===== 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:
```python
# ===== 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**
```bash
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**
```bash
cat fusion_plating/models/fp_job_step_timelog.py
```
- [ ] **Step 2: Add state + reconciliation fields**
Append to the class:
```python
# ===== 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**
```bash
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`:
```python
# -*- 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:
```python
from . import move_controller
```
- [ ] **Step 3: Commit**
```bash
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:
```python
# ----------------------------------------------------- /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**
```bash
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**
```python
# ====================================================================
# 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**
```bash
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**
```javascript
/** @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
<?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**
```bash
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**
```javascript
/** @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
<?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**
```bash
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.
```javascript
/** @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
<?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**
```javascript
/** @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
<?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**
```bash
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**
```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**
```bash
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**
```bash
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:
```javascript
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:
```javascript
window.addEventListener("fp-resolve-rack", (ev) => {
this.dialog.add(FpRackPartsDialog, {
fromStepId: ev.detail.fromStepId,
qty: 0,
onRacked: () => this.refreshTablet(),
});
});
```
Add new methods:
```javascript
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`**
```javascript
import { FpMoveRackDialog } from "./move_rack_dialog";
```
```javascript
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):
```xml
<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:
```xml
<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:
```xml
<button class="btn btn-sm btn-danger"
t-on-click="() => this.openMoveRackDialog(rack.id, rack.next_step_id)">
MOVE RACK
</button>
```
- [ ] **Step 5: Commit**
```bash
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:
```python
# 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:
```xml
<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**
```javascript
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**
```bash
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:
```python
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:
```python
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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.**