# 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