diff --git a/fusion_plating/docs/superpowers/plans/2026-04-28-sub12b-tablet-move-rack-timer.md b/fusion_plating/docs/superpowers/plans/2026-04-28-sub12b-tablet-move-rack-timer.md new file mode 100644 index 00000000..a8ec892f --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-04-28-sub12b-tablet-move-rack-timer.md @@ -0,0 +1,2772 @@ +# 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 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-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 - | 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) " +``` + +--- + +## 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 + + + + + + fp.rack.tag.list + fp.rack.tag + + + + + + + + + + + + Rack Tags + fp.rack.tag + list + + + + + +``` + +- [ ] **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) " +``` + +--- + +## 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 + + + + + + + + + + + + + + + + + + + + + + + +``` + +(If no `
` exists in the rack view, add one with `` 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) " +``` + +--- + +## 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 `` root): + +```xml + + FP — Move Log + fp.job.step.move + FP/MOVE/%(year)s/ + 5 + + + + + FP — Labor Timer + fp.labor.timer + FP/TIMER/%(year)s/ + 5 + + +``` + +- [ ] **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) " +``` + +--- + +## 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 + + + + + + fp.job.step.move.list + fp.job.step.move + + + + + + + + + + + + + + + + + + + fp.job.step.move.form + fp.job.step.move + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Move Log + fp.job.step.move + list,form + + + + +
+``` + +- [ ] **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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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: + 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: 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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 + + + + + +
+
+ + + Available: +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
Compliance Prompts
+
+ + + + + + + +
+
+ +
+
Blockers
+
+ + + +
+
+
+ +
Loading…
+ + + + + +
+
+ +
+``` + +- [ ] **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) " +``` + +--- + +## 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::")); + 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 + + + + + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ +
+``` + +- [ ] **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/) +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) " +``` + +--- + +## 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 + + + + + +
+
+ + + + +
+
+ +
    + +
  • + + on WO +
  • +
    +
+
+
+ + +
+
+ + +
+
+ + +
+
+
Loading…
+ + + + + +
+
+ +
+``` + +- [ ] **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 + + + + + +
+
+ Accrued: sec + · Billed: % +
+
+ + hrs + min + sec +
+
+ +