Adjustments from the spec, captured upfront in the plan: - fp.rack already exists (extend, don't create) — Task 3 - fp.labor.timer collapses into the existing fp.job.step.timelog with a state machine + reconciliation fields — Task 7. Avoids parallel labor-tracking models; keeps battle-test S1/S2 paths intact. - Sub 12b's Save+Print on Rack Parts references a report that lands in Sub 12c — flagged in Task 12 body. 18 tasks cover: 4 new models (rack tag, move, move input value), state machine on existing rack + timelog, 11 controller endpoints, 4 OWL dialogs, plant overview 2-pane layout, runtime guards, manager bypass flags, entech deployment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2773 lines
103 KiB
Markdown
2773 lines
103 KiB
Markdown
# 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 - <files> | ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar -xf -'"
|
||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
||
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
||
-u fusion_plating --stop-after-init\" 2>&1 | tail -20 && \
|
||
systemctl start odoo'"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Bump versions + scaffold manifests
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating/__manifest__.py`
|
||
- Modify: `fusion_plating_shopfloor/__manifest__.py`
|
||
|
||
- [ ] **Step 1: Bump fusion_plating version**
|
||
|
||
```python
|
||
# fusion_plating/__manifest__.py
|
||
'version': '19.0.10.0.0' → '19.0.10.1.0',
|
||
```
|
||
|
||
Add to `'data'` list (after `views/fp_step_template_views.xml`):
|
||
```python
|
||
'views/fp_rack_tag_views.xml',
|
||
'views/fp_job_step_move_views.xml',
|
||
```
|
||
|
||
- [ ] **Step 2: Bump fusion_plating_shopfloor version**
|
||
|
||
Read it first to find current version:
|
||
```bash
|
||
grep "version" fusion_plating_shopfloor/__manifest__.py | head -1
|
||
```
|
||
|
||
Bump to next patch number (e.g. if current is `19.0.24.4.0`, bump to `19.0.25.0.0`).
|
||
|
||
Add to `'assets' → 'web.assets_backend'`:
|
||
```python
|
||
'fusion_plating_shopfloor/static/src/scss/move_dialogs.scss',
|
||
'fusion_plating_shopfloor/static/src/js/move_parts_dialog.js',
|
||
'fusion_plating_shopfloor/static/src/js/move_rack_dialog.js',
|
||
'fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js',
|
||
'fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js',
|
||
'fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml',
|
||
'fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml',
|
||
'fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml',
|
||
'fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml',
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/__manifest__.py fusion_plating_shopfloor/__manifest__.py
|
||
git commit -m "feat(sub12b): bump versions + scaffold manifests
|
||
|
||
fusion_plating → 19.0.10.1.0
|
||
fusion_plating_shopfloor → next patch
|
||
|
||
Adds data entries for the 2 new view files (rack_tag + job_step_move).
|
||
Adds 4 OWL dialogs + their templates + shared SCSS to the shopfloor
|
||
backend asset bundle.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add `fp.rack.tag` model (rack-label registry)
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating/models/fp_rack_tag.py`
|
||
- Create: `fusion_plating/views/fp_rack_tag_views.xml`
|
||
- Modify: `fusion_plating/models/__init__.py`
|
||
|
||
- [ ] **Step 1: Create the model**
|
||
|
||
`fusion_plating/models/fp_rack_tag.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
# Part of the Fusion Plating product family.
|
||
|
||
from odoo import fields, models
|
||
|
||
|
||
class FpRackTag(models.Model):
|
||
"""Operator-visible labels applied to physical racks.
|
||
|
||
"Rush" / "Hold for QC" / "Customer-Amphenol" / "Damaged" — the
|
||
coloured tag chips that appear in the Move Rack dialog and on the
|
||
plant-overview rack rows. M2M; one rack can carry many tags.
|
||
"""
|
||
_name = 'fp.rack.tag'
|
||
_description = 'Fusion Plating — Rack Tag'
|
||
_order = 'sequence, name'
|
||
|
||
name = fields.Char(string='Tag', required=True, translate=True)
|
||
color = fields.Integer(string='Color')
|
||
sequence = fields.Integer(string='Sequence', default=10)
|
||
active = fields.Boolean(string='Active', default=True)
|
||
company_id = fields.Many2one(
|
||
'res.company', string='Company',
|
||
default=lambda self: self.env.company,
|
||
)
|
||
|
||
_sql_constraints = [
|
||
('fp_rack_tag_name_company_uniq',
|
||
'unique(name, company_id)',
|
||
'Rack tag name must be unique within a company.'),
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Create the views**
|
||
|
||
`fusion_plating/views/fp_rack_tag_views.xml`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<!--
|
||
Copyright 2026 Nexa Systems Inc.
|
||
License OPL-1 (Odoo Proprietary License v1.0)
|
||
Part of the Fusion Plating product family.
|
||
-->
|
||
<odoo>
|
||
|
||
<record id="view_fp_rack_tag_list" model="ir.ui.view">
|
||
<field name="name">fp.rack.tag.list</field>
|
||
<field name="model">fp.rack.tag</field>
|
||
<field name="arch" type="xml">
|
||
<list string="Rack Tags" editable="bottom">
|
||
<field name="sequence" widget="handle"/>
|
||
<field name="name"/>
|
||
<field name="color" widget="color_picker"/>
|
||
<field name="active" widget="boolean_toggle"/>
|
||
</list>
|
||
</field>
|
||
</record>
|
||
|
||
<record id="action_fp_rack_tag" model="ir.actions.act_window">
|
||
<field name="name">Rack Tags</field>
|
||
<field name="res_model">fp.rack.tag</field>
|
||
<field name="view_mode">list</field>
|
||
</record>
|
||
|
||
<menuitem id="menu_fp_rack_tags"
|
||
name="Rack Tags"
|
||
parent="menu_fp_config"
|
||
action="action_fp_rack_tag"
|
||
sequence="48"/>
|
||
|
||
</odoo>
|
||
```
|
||
|
||
- [ ] **Step 3: Wire into __init__**
|
||
|
||
In `fusion_plating/models/__init__.py`, add after the Sub 12a block:
|
||
```python
|
||
# Sub 12b — Rack-aware moves + persistent labor reconciliation
|
||
from . import fp_rack_tag
|
||
```
|
||
|
||
- [ ] **Step 4: ACL rows**
|
||
|
||
Append to `fusion_plating/security/ir.model.access.csv`:
|
||
```csv
|
||
access_fp_rack_tag_operator,fp.rack.tag.operator,model_fp_rack_tag,group_fusion_plating_operator,1,0,0,0
|
||
access_fp_rack_tag_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,group_fusion_plating_supervisor,1,1,1,1
|
||
access_fp_rack_tag_manager,fp.rack.tag.manager,model_fp_rack_tag,group_fusion_plating_manager,1,1,1,1
|
||
```
|
||
|
||
- [ ] **Step 5: Seed 4 starter tags via post_init_hook**
|
||
|
||
In `fusion_plating/__init__.py`, append a new helper called from the existing hook:
|
||
|
||
```python
|
||
def post_init_hook(env):
|
||
_seed_default_timezone(env)
|
||
_backfill_node_input_kind(env)
|
||
_seed_step_library_if_empty(env)
|
||
_seed_rack_tags_if_empty(env) # NEW
|
||
|
||
|
||
def _seed_rack_tags_if_empty(env):
|
||
"""Sub 12b — seed 4 starter rack tags."""
|
||
Tag = env['fp.rack.tag']
|
||
if Tag.search_count([]):
|
||
return
|
||
starters = [
|
||
('Rush', 1),
|
||
('Hold for QC', 3),
|
||
('Damaged', 9),
|
||
('Customer Sample', 5),
|
||
]
|
||
for name, color in starters:
|
||
Tag.create({'name': name, 'color': color})
|
||
_logger.info(
|
||
'Fusion Plating: seeded %s starter rack tags', len(starters),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/models/fp_rack_tag.py \
|
||
fusion_plating/models/__init__.py \
|
||
fusion_plating/views/fp_rack_tag_views.xml \
|
||
fusion_plating/security/ir.model.access.csv \
|
||
fusion_plating/__init__.py
|
||
git commit -m "feat(sub12b): fp.rack.tag — rack-label registry
|
||
|
||
M2M tag registry: Rush / Hold for QC / Damaged / Customer Sample.
|
||
Each rack can carry many tags; tags surface as coloured chips in the
|
||
Move Rack dialog and on plant-overview rack rows.
|
||
|
||
Seeded with 4 starter tags via post_init_hook (idempotent).
|
||
Plating → Configuration → Rack Tags menu added (sequence 48).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Extend `fusion.plating.rack` with state machine + new fields
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating/models/fp_rack.py`
|
||
- Modify: `fusion_plating/views/fp_rack_views.xml`
|
||
|
||
- [ ] **Step 1: Read existing model + view**
|
||
|
||
```bash
|
||
cat fusion_plating/models/fp_rack.py
|
||
grep -n "view_fp_rack" fusion_plating/views/fp_rack_views.xml
|
||
```
|
||
|
||
- [ ] **Step 2: Extend `fusion.plating.rack`**
|
||
|
||
Add to the model class:
|
||
|
||
```python
|
||
# ===== Sub 12b — state machine + tags + capacity =====================
|
||
state = fields.Selection(
|
||
[
|
||
('empty', 'Empty'),
|
||
('loading', 'Loading'),
|
||
('loaded', 'Loaded'),
|
||
('in_use', 'In Use'),
|
||
('awaiting_unrack', 'Awaiting Unrack'),
|
||
('out_of_service', 'Out of Service'),
|
||
],
|
||
string='State', default='empty', tracking=True,
|
||
)
|
||
tag_ids = fields.Many2many(
|
||
'fp.rack.tag',
|
||
'fp_rack_tag_rel', 'rack_id', 'tag_id',
|
||
string='Tags',
|
||
)
|
||
capacity_count = fields.Integer(
|
||
string='Capacity (parts)',
|
||
help='Soft warning threshold — runtime informs operator when '
|
||
'rack is loaded beyond this. Not enforced.',
|
||
)
|
||
notes = fields.Text(string='Maintenance Notes')
|
||
|
||
current_job_step_id = fields.Many2one(
|
||
'fp.job.step', string='Current Step',
|
||
compute='_compute_current_state',
|
||
store=True,
|
||
)
|
||
current_tank_id = fields.Many2one(
|
||
'fusion.plating.tank', string='Current Tank',
|
||
related='current_job_step_id.tank_id',
|
||
store=True,
|
||
)
|
||
current_part_count = fields.Integer(
|
||
string='Parts on Rack',
|
||
compute='_compute_current_state',
|
||
store=True,
|
||
)
|
||
|
||
@api.depends('state') # trigger; real source is fp.job.step.move via inverse
|
||
def _compute_current_state(self):
|
||
# Walk the most recent FP move(s) per rack to find current job step + qty.
|
||
# For racks with no in-flight moves, all values are blank.
|
||
Move = self.env['fp.job.step.move']
|
||
for rack in self:
|
||
if rack.state in ('empty', 'out_of_service'):
|
||
rack.current_job_step_id = False
|
||
rack.current_part_count = 0
|
||
continue
|
||
recent = Move.search(
|
||
[('rack_id', '=', rack.id)],
|
||
order='move_datetime desc',
|
||
limit=1,
|
||
)
|
||
rack.current_job_step_id = recent.to_step_id if recent else False
|
||
rack.current_part_count = sum(
|
||
Move.search(
|
||
[('rack_id', '=', rack.id),
|
||
('move_datetime', '>=', recent.move_datetime if recent else False)]
|
||
).mapped('qty_moved')
|
||
) if recent else 0
|
||
```
|
||
|
||
- [ ] **Step 3: Surface the new fields on the rack form**
|
||
|
||
In `fusion_plating/views/fp_rack_views.xml`, find the existing form view and add a state header + new fields:
|
||
|
||
```xml
|
||
<field name="arch" type="xml">
|
||
<data>
|
||
<xpath expr="//header" position="inside">
|
||
<field name="state" widget="statusbar"
|
||
statusbar_visible="empty,loading,loaded,in_use,awaiting_unrack"/>
|
||
</xpath>
|
||
<xpath expr="//sheet" position="inside">
|
||
<group>
|
||
<group string="Tags + Capacity">
|
||
<field name="tag_ids" widget="many2many_tags"
|
||
options="{'color_field': 'color'}"/>
|
||
<field name="capacity_count"/>
|
||
</group>
|
||
<group string="Current Use" invisible="state in ('empty','out_of_service')">
|
||
<field name="current_job_step_id" readonly="1"/>
|
||
<field name="current_tank_id" readonly="1"/>
|
||
<field name="current_part_count" readonly="1"/>
|
||
</group>
|
||
</group>
|
||
<group string="Notes">
|
||
<field name="notes" nolabel="1"/>
|
||
</group>
|
||
</xpath>
|
||
</data>
|
||
</field>
|
||
```
|
||
|
||
(If no `<header>` exists in the rack view, add one with `<xpath expr="//form" position="inside">` instead.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/models/fp_rack.py fusion_plating/views/fp_rack_views.xml
|
||
git commit -m "feat(sub12b): extend fusion.plating.rack — state + tags + capacity
|
||
|
||
State machine: empty → loading → loaded → in_use → awaiting_unrack →
|
||
empty (or out_of_service from any state).
|
||
|
||
New fields: tag_ids (M2M to fp.rack.tag), capacity_count (soft warn),
|
||
notes, plus 3 computes (current_job_step_id, current_tank_id,
|
||
current_part_count) that walk fp.job.step.move history.
|
||
|
||
The current_* fields stay correct as moves happen — the compute
|
||
re-fires when state changes (and the controller bumps state on every
|
||
move-rack call).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Create `fp.job.step.move` + `fp.job.step.move.input.value`
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating/models/fp_job_step_move.py`
|
||
- Modify: `fusion_plating/models/__init__.py`
|
||
- Modify: `fusion_plating/data/fp_sequence_data.xml`
|
||
|
||
- [ ] **Step 1: Add the move-log sequence**
|
||
|
||
Append to `fusion_plating/data/fp_sequence_data.xml` (inside the existing `<odoo>` root):
|
||
|
||
```xml
|
||
<record id="seq_fp_job_step_move" model="ir.sequence">
|
||
<field name="name">FP — Move Log</field>
|
||
<field name="code">fp.job.step.move</field>
|
||
<field name="prefix">FP/MOVE/%(year)s/</field>
|
||
<field name="padding">5</field>
|
||
<field name="company_id" eval="False"/>
|
||
</record>
|
||
|
||
<record id="seq_fp_labor_timer" model="ir.sequence">
|
||
<field name="name">FP — Labor Timer</field>
|
||
<field name="code">fp.labor.timer</field>
|
||
<field name="prefix">FP/TIMER/%(year)s/</field>
|
||
<field name="padding">5</field>
|
||
<field name="company_id" eval="False"/>
|
||
</record>
|
||
```
|
||
|
||
- [ ] **Step 2: Create the move-log model file**
|
||
|
||
`fusion_plating/models/fp_job_step_move.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
# Part of the Fusion Plating product family.
|
||
|
||
from odoo import api, fields, models
|
||
|
||
|
||
class FpJobStepMove(models.Model):
|
||
"""Chain-of-custody log — one row per part-batch move.
|
||
|
||
Sub 12b: every Move Parts / Move Rack click commits one (or, for
|
||
rack moves, one-per-batch atomic) row here. Sub 12c walks these in
|
||
chronological order to render the customer CoC PDF.
|
||
"""
|
||
_name = 'fp.job.step.move'
|
||
_description = 'Fusion Plating — Job Step Move (Chain-of-Custody)'
|
||
_inherit = ['mail.thread']
|
||
_order = 'move_datetime desc, id desc'
|
||
|
||
name = fields.Char(
|
||
string='Move Reference',
|
||
default=lambda self: self.env['ir.sequence'].next_by_code('fp.job.step.move') or '/',
|
||
readonly=True, copy=False,
|
||
)
|
||
job_id = fields.Many2one('fp.job', string='Job',
|
||
required=True, ondelete='cascade', index=True)
|
||
from_step_id = fields.Many2one('fp.job.step', string='From Step',
|
||
ondelete='set null', index=True)
|
||
to_step_id = fields.Many2one('fp.job.step', string='To Step',
|
||
ondelete='set null', index=True, required=True)
|
||
from_tank_id = fields.Many2one('fusion.plating.tank',
|
||
related='from_step_id.tank_id', store=True)
|
||
to_tank_id = fields.Many2one('fusion.plating.tank', string='To Tank',
|
||
ondelete='set null')
|
||
|
||
transfer_type = fields.Selection([
|
||
('step', 'Step'),
|
||
('hold', 'Hold'),
|
||
('scrap', 'Scrap'),
|
||
('rework', 'Rework'),
|
||
('split', 'Split'),
|
||
('return', 'Return'),
|
||
], string='Transfer Type', default='step', required=True)
|
||
|
||
qty_moved = fields.Integer(string='Qty Moved', required=True)
|
||
qty_available_at_move = fields.Integer(string='Qty Available')
|
||
|
||
to_location = fields.Selection([
|
||
('global', 'Global'),
|
||
('quarantine', 'Quarantine'),
|
||
('staging_a', 'Staging A'),
|
||
('staging_b', 'Staging B'),
|
||
('shipping_dock', 'Shipping Dock'),
|
||
('scrap_bin', 'Scrap Bin'),
|
||
], string='To Location', default='global')
|
||
|
||
photo_evidence_id = fields.Many2one('ir.attachment',
|
||
string='Photo Evidence', ondelete='set null')
|
||
customer_wo_count = fields.Integer(string='# Customer WOs')
|
||
|
||
rack_id = fields.Many2one('fusion.plating.rack',
|
||
string='Rack', ondelete='set null', index=True)
|
||
unrack_after_move = fields.Boolean(string='Unrack After Move')
|
||
|
||
moved_by_user_id = fields.Many2one('res.users', string='Moved By',
|
||
default=lambda self: self.env.user, required=True)
|
||
move_datetime = fields.Datetime(string='Move Time',
|
||
default=fields.Datetime.now, required=True, index=True)
|
||
|
||
transition_input_value_ids = fields.One2many(
|
||
'fp.job.step.move.input.value', 'move_id',
|
||
string='Transition Input Values',
|
||
)
|
||
|
||
|
||
class FpJobStepMoveInputValue(models.Model):
|
||
"""Captured value for one transition-input prompt.
|
||
|
||
Each row = one author-defined prompt × one move. Snapshot of what
|
||
the operator typed at move-time. Used by Sub 12c CoC report.
|
||
"""
|
||
_name = 'fp.job.step.move.input.value'
|
||
_description = 'Fusion Plating — Captured Transition Input Value'
|
||
_order = 'move_id, id'
|
||
|
||
move_id = fields.Many2one('fp.job.step.move', string='Move',
|
||
required=True, ondelete='cascade', index=True)
|
||
template_input_id = fields.Many2one(
|
||
'fp.step.template.transition.input',
|
||
string='Template Input', ondelete='set null',
|
||
help='What was originally asked (template-level reference).')
|
||
node_input_id = fields.Many2one(
|
||
'fusion.plating.process.node.input',
|
||
string='Node Input', ondelete='set null',
|
||
help='Snapshot of the authored prompt at job-creation time.')
|
||
|
||
value_text = fields.Char(string='Text Value')
|
||
value_number = fields.Float(string='Number Value')
|
||
value_boolean = fields.Boolean(string='Yes/No Value')
|
||
value_date = fields.Datetime(string='Date Value')
|
||
value_attachment_id = fields.Many2one('ir.attachment',
|
||
string='Attachment Value', ondelete='set null')
|
||
```
|
||
|
||
- [ ] **Step 3: Wire into __init__**
|
||
|
||
`fusion_plating/models/__init__.py` — add:
|
||
```python
|
||
from . import fp_job_step_move
|
||
```
|
||
|
||
- [ ] **Step 4: ACL rows**
|
||
|
||
Append to `fusion_plating/security/ir.model.access.csv`:
|
||
```csv
|
||
access_fp_job_step_move_operator,fp.job.step.move.operator,model_fp_job_step_move,group_fusion_plating_operator,1,1,1,0
|
||
access_fp_job_step_move_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,group_fusion_plating_supervisor,1,1,1,0
|
||
access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move,group_fusion_plating_manager,1,1,1,1
|
||
access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,group_fusion_plating_operator,1,1,1,0
|
||
access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,group_fusion_plating_supervisor,1,1,1,0
|
||
access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,group_fusion_plating_manager,1,1,1,1
|
||
```
|
||
|
||
(Operators get read+write+create — they generate moves at runtime — but no unlink. Manager-only deletes for audit safety.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/models/fp_job_step_move.py \
|
||
fusion_plating/models/__init__.py \
|
||
fusion_plating/data/fp_sequence_data.xml \
|
||
fusion_plating/security/ir.model.access.csv
|
||
git commit -m "feat(sub12b): fp.job.step.move + fp.job.step.move.input.value
|
||
|
||
Chain-of-custody log: one row per Move Parts / Move Rack commit.
|
||
FP/MOVE/YYYY/NNNN sequence. Carries from/to step, tank, transfer
|
||
type, qty, location, photo, rack, operator, datetime.
|
||
|
||
Child model captures recorded transition-input values (Sub 12a's
|
||
fp.step.template.transition.input snapshots → fp.job.step.move.
|
||
input.value rows). Each row carries 5 typed value columns; the
|
||
controller picks the right one based on input_type.
|
||
|
||
Operators can create + write but not unlink (audit safety). Manager
|
||
override available for genuine cleanup.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Create move-log views (audit list/form)
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating/views/fp_job_step_move_views.xml`
|
||
|
||
- [ ] **Step 1: Create the views**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<!--
|
||
Copyright 2026 Nexa Systems Inc.
|
||
License OPL-1 (Odoo Proprietary License v1.0)
|
||
Part of the Fusion Plating product family.
|
||
-->
|
||
<odoo>
|
||
|
||
<record id="view_fp_job_step_move_list" model="ir.ui.view">
|
||
<field name="name">fp.job.step.move.list</field>
|
||
<field name="model">fp.job.step.move</field>
|
||
<field name="arch" type="xml">
|
||
<list string="Move Log" default_order="move_datetime desc">
|
||
<field name="name"/>
|
||
<field name="move_datetime"/>
|
||
<field name="job_id"/>
|
||
<field name="from_step_id"/>
|
||
<field name="to_step_id"/>
|
||
<field name="from_tank_id"/>
|
||
<field name="to_tank_id"/>
|
||
<field name="qty_moved"/>
|
||
<field name="transfer_type" widget="badge"/>
|
||
<field name="rack_id"/>
|
||
<field name="moved_by_user_id"/>
|
||
</list>
|
||
</field>
|
||
</record>
|
||
|
||
<record id="view_fp_job_step_move_form" model="ir.ui.view">
|
||
<field name="name">fp.job.step.move.form</field>
|
||
<field name="model">fp.job.step.move</field>
|
||
<field name="arch" type="xml">
|
||
<form string="Move" create="false">
|
||
<sheet>
|
||
<div class="oe_title"><h1><field name="name" readonly="1"/></h1></div>
|
||
<group>
|
||
<group>
|
||
<field name="job_id"/>
|
||
<field name="from_step_id"/>
|
||
<field name="to_step_id"/>
|
||
<field name="transfer_type"/>
|
||
<field name="qty_moved"/>
|
||
<field name="qty_available_at_move"/>
|
||
</group>
|
||
<group>
|
||
<field name="from_tank_id"/>
|
||
<field name="to_tank_id"/>
|
||
<field name="to_location"/>
|
||
<field name="rack_id"/>
|
||
<field name="customer_wo_count"/>
|
||
<field name="moved_by_user_id"/>
|
||
<field name="move_datetime"/>
|
||
</group>
|
||
</group>
|
||
<notebook>
|
||
<page string="Captured Inputs" name="captured_inputs">
|
||
<field name="transition_input_value_ids" readonly="1">
|
||
<list>
|
||
<field name="node_input_id"/>
|
||
<field name="value_text"/>
|
||
<field name="value_number"/>
|
||
<field name="value_boolean"/>
|
||
<field name="value_date"/>
|
||
<field name="value_attachment_id"/>
|
||
</list>
|
||
</field>
|
||
</page>
|
||
<page string="Photo Evidence" name="photo"
|
||
invisible="not photo_evidence_id">
|
||
<field name="photo_evidence_id" widget="image"/>
|
||
</page>
|
||
</notebook>
|
||
</sheet>
|
||
<chatter/>
|
||
</form>
|
||
</field>
|
||
</record>
|
||
|
||
<record id="action_fp_job_step_move" model="ir.actions.act_window">
|
||
<field name="name">Move Log</field>
|
||
<field name="res_model">fp.job.step.move</field>
|
||
<field name="view_mode">list,form</field>
|
||
</record>
|
||
|
||
<menuitem id="menu_fp_job_step_move"
|
||
name="Move Log"
|
||
parent="menu_fp_root"
|
||
action="action_fp_job_step_move"
|
||
sequence="62"/>
|
||
|
||
</odoo>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/views/fp_job_step_move_views.xml
|
||
git commit -m "feat(sub12b): move-log list/form views + Plating menu entry
|
||
|
||
Plating → Move Log (sequence 62, between Logistics 60 and Aerospace 65).
|
||
Form is read-only (create=false) since moves are produced by the
|
||
tablet flow, not the desktop UI.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Extend `fp.job.step` + `fp.job` with new fields
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating/models/fp_job_step.py`
|
||
- Modify: `fusion_plating/models/fp_job.py`
|
||
|
||
- [ ] **Step 1: Read both files to find safe insertion points**
|
||
|
||
```bash
|
||
grep -n "_name\|_inherit\|class\|_sql_constraints" fusion_plating/models/fp_job_step.py
|
||
grep -n "_name\|_inherit\|class\|_sql_constraints" fusion_plating/models/fp_job.py
|
||
```
|
||
|
||
- [ ] **Step 2: Extend `fp.job.step`**
|
||
|
||
Add these fields after the existing field block:
|
||
|
||
```python
|
||
# ===== Sub 12b — chain-of-custody + rack awareness =====================
|
||
move_ids = fields.One2many(
|
||
'fp.job.step.move', 'from_step_id',
|
||
string='Outgoing Moves',
|
||
)
|
||
incoming_move_ids = fields.One2many(
|
||
'fp.job.step.move', 'to_step_id',
|
||
string='Incoming Moves',
|
||
)
|
||
current_rack_id = fields.Many2one(
|
||
'fusion.plating.rack', string='Current Rack',
|
||
ondelete='set null', index=True,
|
||
help='When set, this batch of parts is loaded on a rack and '
|
||
'moves only via the Move Rack flow. The Move Parts button '
|
||
'on the tablet greys out.',
|
||
)
|
||
is_racked = fields.Boolean(
|
||
string='Racked', compute='_compute_is_racked', store=True,
|
||
)
|
||
qty_at_step_start = fields.Integer(string='Qty at Step Start')
|
||
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
|
||
|
||
@api.depends('current_rack_id')
|
||
def _compute_is_racked(self):
|
||
for rec in self:
|
||
rec.is_racked = bool(rec.current_rack_id)
|
||
```
|
||
|
||
- [ ] **Step 3: Extend `fp.job`**
|
||
|
||
Add these fields after the existing field block:
|
||
|
||
```python
|
||
# ===== Sub 12b — traveller header + active timer ========================
|
||
qty_received = fields.Integer(string='Qty Received',
|
||
help='From paper traveller "Qty Rec." column.')
|
||
qty_visual_inspection_rejects = fields.Integer(string='Visual Insp Rejects',
|
||
help='From paper traveller "VIS INSP." column.')
|
||
qty_rework = fields.Integer(string='Qty Sent to Rework',
|
||
help='From paper traveller "Rework" column.')
|
||
special_requirements = fields.Text(string='Special Requirements',
|
||
help='Long free-form spec text from customer; printed on '
|
||
'traveller header.')
|
||
active_timer_ids = fields.One2many(
|
||
'fp.job.step.timelog', 'job_id',
|
||
string='Active Timers',
|
||
domain=[('state', 'in', ('running', 'paused'))],
|
||
)
|
||
```
|
||
|
||
(Note: `fp.job.step.timelog` will get the `state` + `job_id` fields in Task 7.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/models/fp_job_step.py fusion_plating/models/fp_job.py
|
||
git commit -m "feat(sub12b): fp.job.step + fp.job — rack + move + traveller header
|
||
|
||
fp.job.step:
|
||
+ move_ids (O2M outgoing) + incoming_move_ids
|
||
+ current_rack_id, is_racked (compute, stored)
|
||
+ qty_at_step_start, qty_at_step_finish
|
||
|
||
fp.job:
|
||
+ qty_received, qty_visual_inspection_rejects, qty_rework
|
||
+ special_requirements (Text — long customer-spec callout)
|
||
+ active_timer_ids (filtered O2M, used by tablet for live timer
|
||
badge display)
|
||
|
||
All additive. No removed fields. Existing battle tests unaffected.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Extend `fp.job.step.timelog` with persistent state machine
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating/models/fp_job_step_timelog.py`
|
||
|
||
- [ ] **Step 1: Read the existing timelog**
|
||
|
||
```bash
|
||
cat fusion_plating/models/fp_job_step_timelog.py
|
||
```
|
||
|
||
- [ ] **Step 2: Add state + reconciliation fields**
|
||
|
||
Append to the class:
|
||
|
||
```python
|
||
# ===== Sub 12b — persistent timer state machine =========================
|
||
state = fields.Selection(
|
||
[
|
||
('running', 'Running'),
|
||
('paused', 'Paused'),
|
||
('stopped', 'Stopped'),
|
||
('reconciled', 'Reconciled'),
|
||
],
|
||
string='State', default='running', tracking=True,
|
||
)
|
||
job_id = fields.Many2one(
|
||
'fp.job', related='step_id.job_id',
|
||
store=True, string='Job', index=True,
|
||
)
|
||
last_paused_at = fields.Datetime(string='Last Paused')
|
||
total_paused_seconds = fields.Integer(
|
||
string='Total Paused (sec)', default=0,
|
||
help='Cumulative time spent in paused state since started_at.',
|
||
)
|
||
accrued_seconds = fields.Integer(
|
||
string='Accrued (sec)',
|
||
compute='_compute_accrued_seconds',
|
||
help='Live seconds since started_at, minus total_paused_seconds. '
|
||
'Recomputed on read for running timers; frozen for stopped/'
|
||
'reconciled.',
|
||
)
|
||
billed_hrs = fields.Integer(string='Billed Hours')
|
||
billed_min = fields.Integer(string='Billed Minutes')
|
||
billed_sec = fields.Integer(string='Billed Seconds')
|
||
billed_total_seconds = fields.Integer(
|
||
string='Billed Total (sec)',
|
||
compute='_compute_billed_total_seconds', store=True,
|
||
)
|
||
billed_pct = fields.Float(
|
||
string='% Billed', compute='_compute_billed_pct',
|
||
help='billed_total / accrued × 100',
|
||
)
|
||
product_id = fields.Many2one(
|
||
'product.product', string='Reconciled Product',
|
||
help='When the operator splits a timer across multiple products, '
|
||
'this row carries the destination product.',
|
||
)
|
||
notes = fields.Text(string='Operator Notes')
|
||
|
||
@api.depends(
|
||
'state', 'started_at', 'stopped_at', 'last_paused_at',
|
||
'total_paused_seconds',
|
||
)
|
||
def _compute_accrued_seconds(self):
|
||
from datetime import datetime
|
||
now = fields.Datetime.now()
|
||
for rec in self:
|
||
if not rec.started_at:
|
||
rec.accrued_seconds = 0
|
||
continue
|
||
end = rec.stopped_at or now
|
||
elapsed = (end - rec.started_at).total_seconds()
|
||
rec.accrued_seconds = max(0, int(elapsed) - (rec.total_paused_seconds or 0))
|
||
|
||
@api.depends('billed_hrs', 'billed_min', 'billed_sec')
|
||
def _compute_billed_total_seconds(self):
|
||
for rec in self:
|
||
rec.billed_total_seconds = (
|
||
(rec.billed_hrs or 0) * 3600
|
||
+ (rec.billed_min or 0) * 60
|
||
+ (rec.billed_sec or 0)
|
||
)
|
||
|
||
@api.depends('billed_total_seconds', 'accrued_seconds')
|
||
def _compute_billed_pct(self):
|
||
for rec in self:
|
||
if rec.accrued_seconds:
|
||
rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds
|
||
else:
|
||
rec.billed_pct = 0.0
|
||
```
|
||
|
||
(Note: this assumes the existing timelog has `step_id`, `started_at`, `stopped_at` fields — typical for a timelog. If field names differ, adjust the related/depends accordingly.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating/models/fp_job_step_timelog.py
|
||
git commit -m "feat(sub12b): persistent state machine on fp.job.step.timelog
|
||
|
||
Extends the existing timelog (used by S1/S2 battle tests) with:
|
||
- state: running / paused / stopped / reconciled
|
||
- last_paused_at, total_paused_seconds (drives accrued compute)
|
||
- accrued_seconds (compute, live for running rows)
|
||
- billed_hrs/min/sec + billed_total_seconds + billed_pct (compute)
|
||
- product_id (for split-by-product reconciliation per screen 10)
|
||
- notes
|
||
- job_id (related, indexed — for fast O2M from fp.job.active_timer_ids)
|
||
|
||
The existing battle tests use the timelog without state — default is
|
||
'running' so they're unaffected. State only flips when Sub 12b's Stop
|
||
Timer dialog commits.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Tablet controller — Move Parts endpoints
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating_shopfloor/controllers/move_controller.py`
|
||
- Modify: `fusion_plating_shopfloor/controllers/__init__.py`
|
||
|
||
- [ ] **Step 1: Create the controller skeleton + Move Parts endpoints**
|
||
|
||
`fusion_plating_shopfloor/controllers/move_controller.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
# Part of the Fusion Plating product family.
|
||
"""Tablet endpoints for Sub 12b — Move Parts / Move Rack / Rack Parts /
|
||
Stop Timer dialogs.
|
||
|
||
All routes JSONRPC, auth='user'. Errors return {ok: False, error: msg}
|
||
so the tablet UI can show a flash without crashing the OWL component.
|
||
"""
|
||
|
||
from odoo import _, http
|
||
from odoo.exceptions import UserError
|
||
from odoo.http import request
|
||
|
||
|
||
# Selection of fp.job.step.move fields the preview endpoint exposes.
|
||
_MOVE_PREVIEW_FIELDS = [
|
||
'qty_available', 'from_node_name', 'from_tank_name',
|
||
'to_node_name', 'to_tank_options', 'transition_prompts',
|
||
'blockers',
|
||
]
|
||
|
||
|
||
class FpTabletMoveController(http.Controller):
|
||
"""Move Parts / Move Rack / Rack Parts / Stop Timer."""
|
||
|
||
# ------------------------------------------------------------- helpers
|
||
|
||
def _safe(self, method, *args, **kwargs):
|
||
"""Wrap UserError → JSONRPC-friendly {ok: False, error: msg}."""
|
||
try:
|
||
return {'ok': True, **method(*args, **kwargs)}
|
||
except UserError as exc:
|
||
return {'ok': False, 'error': str(exc.args[0])}
|
||
|
||
def _step_payload(self, step):
|
||
return {
|
||
'id': step.id,
|
||
'name': step.name,
|
||
'tank_id': step.tank_id.id if step.tank_id else False,
|
||
'tank_name': step.tank_id.name if step.tank_id else '',
|
||
'tank_options': [
|
||
{'id': t.id, 'name': t.name, 'code': t.code}
|
||
for t in step.tank_ids
|
||
] if 'tank_ids' in step._fields else [],
|
||
'requires_rack_assignment': step.requires_rack_assignment,
|
||
'requires_transition_form': step.requires_transition_form,
|
||
}
|
||
|
||
def _transition_prompts_for(self, step):
|
||
"""Returns the snapshot transition_input rows on a step."""
|
||
prompts = []
|
||
for inp in step.input_ids.filtered(lambda i: i.kind == 'transition_input'):
|
||
prompts.append({
|
||
'id': inp.id,
|
||
'name': inp.name,
|
||
'input_type': inp.input_type,
|
||
'required': inp.required,
|
||
'hint': inp.hint or '',
|
||
'selection_options': inp.selection_options or '',
|
||
'compliance_tag': inp.compliance_tag,
|
||
})
|
||
return prompts
|
||
|
||
def _blockers_for_move(self, from_step, to_step, qty):
|
||
"""Compute the blockers list. Each blocker has type/severity/message/
|
||
resolve_action keys; the tablet renders them with inline buttons."""
|
||
blockers = []
|
||
# 1. Rack-required gate (soft block — operator may RACK PARTS)
|
||
if to_step.requires_rack_assignment and not from_step.current_rack_id:
|
||
blockers.append({
|
||
'type': 'rack_required',
|
||
'severity': 'soft',
|
||
'message': _("Parts must be racked before moving to %s.") % to_step.name,
|
||
'resolve_action': 'open_rack_parts_dialog',
|
||
})
|
||
# 2. Predecessor lock (S14 hard block)
|
||
if to_step.requires_predecessor_done:
|
||
unfinished = to_step.job_id.step_ids.filtered(
|
||
lambda s: s.sequence < to_step.sequence
|
||
and s.state not in ('done', 'skipped', 'cancelled')
|
||
)
|
||
if unfinished:
|
||
blockers.append({
|
||
'type': 'predecessor_lock',
|
||
'severity': 'hard',
|
||
'message': _("Predecessor not done: %s") % unfinished[0].name,
|
||
'resolve_action': 'open_predecessor_step',
|
||
'resolve_step_id': unfinished[0].id,
|
||
})
|
||
return blockers
|
||
|
||
# ----------------------------------------------------- /move_parts/preview
|
||
@http.route('/fp/tablet/move_parts/preview', type='jsonrpc', auth='user')
|
||
def move_parts_preview(self, from_step_id, to_step_id, qty=None):
|
||
Step = request.env['fp.job.step']
|
||
from_step = Step.browse(from_step_id)
|
||
to_step = Step.browse(to_step_id)
|
||
qty = qty or (from_step.qty_done - from_step.qty_scrapped)
|
||
return {
|
||
'ok': True,
|
||
'qty_available': qty,
|
||
'from_step': self._step_payload(from_step),
|
||
'to_step': self._step_payload(to_step),
|
||
'transition_prompts': self._transition_prompts_for(to_step),
|
||
'blockers': self._blockers_for_move(from_step, to_step, qty),
|
||
}
|
||
|
||
# ------------------------------------------------------ /move_parts/commit
|
||
@http.route('/fp/tablet/move_parts/commit', type='jsonrpc', auth='user')
|
||
def move_parts_commit(self, from_step_id, to_step_id, qty,
|
||
transfer_type='step', to_tank_id=False,
|
||
to_location='global', photo_attachment_id=False,
|
||
customer_wo_count=0, prompt_values=None):
|
||
return self._safe(self._do_move_parts_commit,
|
||
from_step_id, to_step_id, qty, transfer_type,
|
||
to_tank_id, to_location, photo_attachment_id,
|
||
customer_wo_count, prompt_values or {},
|
||
)
|
||
|
||
def _do_move_parts_commit(self, from_step_id, to_step_id, qty,
|
||
transfer_type, to_tank_id, to_location,
|
||
photo_attachment_id, customer_wo_count,
|
||
prompt_values):
|
||
Step = request.env['fp.job.step']
|
||
Move = request.env['fp.job.step.move']
|
||
from_step = Step.browse(from_step_id)
|
||
to_step = Step.browse(to_step_id)
|
||
|
||
# Hard-block re-check on commit (defence in depth — preview can lie
|
||
# if state changed between preview and commit).
|
||
blockers = self._blockers_for_move(from_step, to_step, qty)
|
||
hard = [b for b in blockers if b['severity'] == 'hard']
|
||
if hard:
|
||
raise UserError(hard[0]['message'])
|
||
|
||
move = Move.create({
|
||
'job_id': from_step.job_id.id,
|
||
'from_step_id': from_step.id,
|
||
'to_step_id': to_step.id,
|
||
'transfer_type': transfer_type,
|
||
'qty_moved': qty,
|
||
'qty_available_at_move': from_step.qty_done - from_step.qty_scrapped,
|
||
'to_tank_id': to_tank_id or False,
|
||
'to_location': to_location,
|
||
'photo_evidence_id': photo_attachment_id or False,
|
||
'customer_wo_count': customer_wo_count or 0,
|
||
})
|
||
|
||
# Capture prompt values
|
||
for prompt_id, value in (prompt_values or {}).items():
|
||
self._capture_prompt_value(move, int(prompt_id), value)
|
||
|
||
# Advance qty_at_step counters
|
||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
|
||
|
||
return {'move_id': move.id, 'move_name': move.name}
|
||
|
||
def _capture_prompt_value(self, move, node_input_id, value):
|
||
Value = request.env['fp.job.step.move.input.value']
|
||
node_input = request.env['fusion.plating.process.node.input'].browse(node_input_id)
|
||
payload = {
|
||
'move_id': move.id,
|
||
'node_input_id': node_input.id,
|
||
}
|
||
t = node_input.input_type
|
||
if t in ('text', 'selection', 'time_hms'):
|
||
payload['value_text'] = value or ''
|
||
elif t in ('number', 'temperature', 'thickness', 'time_seconds',
|
||
'customer_wo'):
|
||
payload['value_number'] = float(value) if value not in (None, '') else 0.0
|
||
elif t == 'boolean' or t == 'pass_fail':
|
||
payload['value_boolean'] = bool(value)
|
||
elif t == 'date':
|
||
payload['value_date'] = value or False
|
||
elif t in ('signature', 'photo'):
|
||
payload['value_attachment_id'] = int(value) if value else False
|
||
elif t == 'location_picker':
|
||
payload['value_text'] = value or ''
|
||
else:
|
||
payload['value_text'] = str(value) if value is not None else ''
|
||
Value.create(payload)
|
||
```
|
||
|
||
- [ ] **Step 2: Wire into __init__**
|
||
|
||
`fusion_plating_shopfloor/controllers/__init__.py` — add:
|
||
```python
|
||
from . import move_controller
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/controllers/move_controller.py \
|
||
fusion_plating_shopfloor/controllers/__init__.py
|
||
git commit -m "feat(sub12b): tablet Move Parts endpoints (preview + commit)
|
||
|
||
/fp/tablet/move_parts/preview returns dialog payload: qty_available,
|
||
from/to step + tank info, transition prompt list, blockers list.
|
||
|
||
/fp/tablet/move_parts/commit creates fp.job.step.move + captures
|
||
prompt values typed into fp.job.step.move.input.value (typed dispatch
|
||
on input_type → correct value_* column).
|
||
|
||
Hard blockers re-checked on commit (defence in depth — UI can lie
|
||
if state changes between preview/commit).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Tablet controller — Move Rack + Rack Parts endpoints
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating_shopfloor/controllers/move_controller.py`
|
||
|
||
- [ ] **Step 1: Append Move Rack endpoints**
|
||
|
||
Add to the controller class:
|
||
|
||
```python
|
||
# ----------------------------------------------------- /move_rack/preview
|
||
@http.route('/fp/tablet/move_rack/preview', type='jsonrpc', auth='user')
|
||
def move_rack_preview(self, rack_id, to_step_id):
|
||
Rack = request.env['fusion.plating.rack']
|
||
Step = request.env['fp.job.step']
|
||
rack = Rack.browse(rack_id)
|
||
to_step = Step.browse(to_step_id)
|
||
# All step batches currently on this rack
|
||
batches = Step.search([('current_rack_id', '=', rack.id)])
|
||
return {
|
||
'ok': True,
|
||
'rack': {
|
||
'id': rack.id,
|
||
'name': rack.name,
|
||
'tag_ids': [
|
||
{'id': t.id, 'name': t.name, 'color': t.color}
|
||
for t in rack.tag_ids
|
||
],
|
||
},
|
||
'batches': [
|
||
{
|
||
'step_id': s.id,
|
||
'qty': s.qty_done - s.qty_scrapped,
|
||
'part_number': s.job_id.product_id.default_code or '',
|
||
'wo_number': s.job_id.name,
|
||
}
|
||
for s in batches
|
||
],
|
||
'to_step': self._step_payload(to_step),
|
||
}
|
||
|
||
# ------------------------------------------------------ /move_rack/commit
|
||
@http.route('/fp/tablet/move_rack/commit', type='jsonrpc', auth='user')
|
||
def move_rack_commit(self, rack_id, to_step_id, transfer_type='step',
|
||
to_tank_id=False):
|
||
return self._safe(self._do_move_rack_commit,
|
||
rack_id, to_step_id, transfer_type, to_tank_id,
|
||
)
|
||
|
||
def _do_move_rack_commit(self, rack_id, to_step_id, transfer_type, to_tank_id):
|
||
Rack = request.env['fusion.plating.rack']
|
||
Step = request.env['fp.job.step']
|
||
Move = request.env['fp.job.step.move']
|
||
rack = Rack.browse(rack_id)
|
||
to_step = Step.browse(to_step_id)
|
||
|
||
moves = []
|
||
for batch in Step.search([('current_rack_id', '=', rack.id)]):
|
||
move = Move.create({
|
||
'job_id': batch.job_id.id,
|
||
'from_step_id': batch.id,
|
||
'to_step_id': to_step.id,
|
||
'transfer_type': transfer_type,
|
||
'qty_moved': batch.qty_done - batch.qty_scrapped,
|
||
'rack_id': rack.id,
|
||
'to_tank_id': to_tank_id or False,
|
||
})
|
||
batch.qty_at_step_finish = batch.qty_done - batch.qty_scrapped
|
||
to_step.qty_at_step_start = (
|
||
(to_step.qty_at_step_start or 0)
|
||
+ batch.qty_done - batch.qty_scrapped
|
||
)
|
||
moves.append(move.id)
|
||
|
||
rack.state = 'in_use'
|
||
return {'move_ids': moves, 'count': len(moves)}
|
||
|
||
# ----------------------------------------------------- /rack_parts/commit
|
||
@http.route('/fp/tablet/rack_parts/commit', type='jsonrpc', auth='user')
|
||
def rack_parts_commit(self, from_step_id, rack_id, qty=None):
|
||
return self._safe(self._do_rack_parts_commit,
|
||
from_step_id, rack_id, qty,
|
||
)
|
||
|
||
def _do_rack_parts_commit(self, from_step_id, rack_id, qty):
|
||
Rack = request.env['fusion.plating.rack']
|
||
Step = request.env['fp.job.step']
|
||
rack = Rack.browse(rack_id)
|
||
if rack.state not in ('empty', 'loading'):
|
||
raise UserError(_("Rack %s is not available (state=%s).") %
|
||
(rack.name, rack.state))
|
||
from_step = Step.browse(from_step_id)
|
||
from_step.current_rack_id = rack.id
|
||
rack.state = 'loaded'
|
||
return {'rack_id': rack.id, 'rack_name': rack.name}
|
||
|
||
# --------------------------------------------------------- /rack/list_empty
|
||
@http.route('/fp/tablet/rack/list_empty', type='jsonrpc', auth='user')
|
||
def rack_list_empty(self, query=''):
|
||
Rack = request.env['fusion.plating.rack']
|
||
domain = [('state', '=', 'empty')]
|
||
if query:
|
||
domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
|
||
records = Rack.search(domain, limit=50)
|
||
return {
|
||
'ok': True,
|
||
'racks': [
|
||
{'id': r.id, 'name': r.name, 'code': r.code}
|
||
for r in records
|
||
],
|
||
}
|
||
|
||
# ------------------------------------------------------- /rack/scan_qr
|
||
@http.route('/fp/tablet/rack/scan_qr', type='jsonrpc', auth='user')
|
||
def rack_scan_qr(self, qr_code):
|
||
# Token format: FP-RACK:<code>
|
||
prefix = 'FP-RACK:'
|
||
if not qr_code.startswith(prefix):
|
||
return {'ok': False, 'error': _("Not a rack QR code.")}
|
||
code = qr_code[len(prefix):]
|
||
rack = request.env['fusion.plating.rack'].search(
|
||
[('code', '=', code)], limit=1,
|
||
)
|
||
if not rack:
|
||
return {'ok': False, 'error': _("Rack %s not found.") % code}
|
||
return {'ok': True, 'rack_id': rack.id, 'rack_name': rack.name,
|
||
'state': rack.state}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/controllers/move_controller.py
|
||
git commit -m "feat(sub12b): Move Rack + Rack Parts + scan_qr endpoints
|
||
|
||
/fp/tablet/move_rack/preview → all batches on rack + to_step info
|
||
/fp/tablet/move_rack/commit → atomic multi-batch move (one
|
||
fp.job.step.move row per batch, all
|
||
tagged with the same rack_id)
|
||
/fp/tablet/rack_parts/commit → assign step → rack, flip rack state
|
||
/fp/tablet/rack/list_empty → empty-rack picker dropdown
|
||
/fp/tablet/rack/scan_qr → resolve FP-RACK:<code> to a rack id
|
||
|
||
All write endpoints _safe-wrapped → tablet flashes the error string
|
||
instead of crashing the OWL component.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Tablet controller — Stop Timer endpoints
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating_shopfloor/controllers/move_controller.py`
|
||
|
||
- [ ] **Step 1: Append timer endpoints**
|
||
|
||
```python
|
||
# ====================================================================
|
||
# Persistent labor timer endpoints (extends fp.job.step.timelog)
|
||
# ====================================================================
|
||
|
||
@http.route('/fp/tablet/labor_timer/start', type='jsonrpc', auth='user')
|
||
def labor_timer_start(self, step_id):
|
||
return self._safe(self._do_labor_timer_start, step_id)
|
||
|
||
def _do_labor_timer_start(self, step_id):
|
||
Tl = request.env['fp.job.step.timelog']
|
||
# End any existing running timer for this user
|
||
running = Tl.search([
|
||
('user_id', '=', request.env.uid),
|
||
('state', '=', 'running'),
|
||
])
|
||
for r in running:
|
||
r.state = 'paused'
|
||
r.last_paused_at = fields.Datetime.now()
|
||
new = Tl.create({
|
||
'step_id': step_id,
|
||
'user_id': request.env.uid,
|
||
'started_at': fields.Datetime.now(),
|
||
'state': 'running',
|
||
})
|
||
return {'timer_id': new.id, 'started_at': new.started_at.isoformat()}
|
||
|
||
@http.route('/fp/tablet/labor_timer/pause', type='jsonrpc', auth='user')
|
||
def labor_timer_pause(self, timer_id):
|
||
return self._safe(self._do_labor_timer_pause, timer_id)
|
||
|
||
def _do_labor_timer_pause(self, timer_id):
|
||
tl = request.env['fp.job.step.timelog'].browse(timer_id)
|
||
if tl.state != 'running':
|
||
raise UserError(_("Timer is not running."))
|
||
tl.state = 'paused'
|
||
tl.last_paused_at = fields.Datetime.now()
|
||
return {'state': 'paused', 'accrued_seconds': tl.accrued_seconds}
|
||
|
||
@http.route('/fp/tablet/labor_timer/resume', type='jsonrpc', auth='user')
|
||
def labor_timer_resume(self, timer_id):
|
||
return self._safe(self._do_labor_timer_resume, timer_id)
|
||
|
||
def _do_labor_timer_resume(self, timer_id):
|
||
tl = request.env['fp.job.step.timelog'].browse(timer_id)
|
||
if tl.state != 'paused':
|
||
raise UserError(_("Timer is not paused."))
|
||
if tl.last_paused_at:
|
||
paused_dur = (fields.Datetime.now() - tl.last_paused_at).total_seconds()
|
||
tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
|
||
tl.state = 'running'
|
||
tl.last_paused_at = False
|
||
return {'state': 'running', 'accrued_seconds': tl.accrued_seconds}
|
||
|
||
@http.route('/fp/tablet/labor_timer/stop', type='jsonrpc', auth='user')
|
||
def labor_timer_stop(self, timer_id):
|
||
return self._safe(self._do_labor_timer_stop, timer_id)
|
||
|
||
def _do_labor_timer_stop(self, timer_id):
|
||
tl = request.env['fp.job.step.timelog'].browse(timer_id)
|
||
if tl.state in ('stopped', 'reconciled'):
|
||
raise UserError(_("Timer already stopped."))
|
||
if tl.state == 'paused' and tl.last_paused_at:
|
||
paused_dur = (fields.Datetime.now() - tl.last_paused_at).total_seconds()
|
||
tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
|
||
tl.last_paused_at = False
|
||
tl.stopped_at = fields.Datetime.now()
|
||
tl.state = 'stopped'
|
||
# Default billed = accrued (operator can edit before reconcile)
|
||
secs = tl.accrued_seconds
|
||
tl.billed_hrs = secs // 3600
|
||
tl.billed_min = (secs % 3600) // 60
|
||
tl.billed_sec = secs % 60
|
||
return {
|
||
'state': 'stopped',
|
||
'accrued_seconds': tl.accrued_seconds,
|
||
'billed_hrs': tl.billed_hrs,
|
||
'billed_min': tl.billed_min,
|
||
'billed_sec': tl.billed_sec,
|
||
}
|
||
|
||
@http.route('/fp/tablet/labor_timer/reconcile', type='jsonrpc', auth='user')
|
||
def labor_timer_reconcile(self, timer_id, billed_hrs=0, billed_min=0,
|
||
billed_sec=0, product_id=False, notes='',
|
||
start_new=False):
|
||
return self._safe(self._do_labor_timer_reconcile,
|
||
timer_id, billed_hrs, billed_min, billed_sec,
|
||
product_id, notes, start_new,
|
||
)
|
||
|
||
def _do_labor_timer_reconcile(self, timer_id, billed_hrs, billed_min,
|
||
billed_sec, product_id, notes, start_new):
|
||
tl = request.env['fp.job.step.timelog'].browse(timer_id)
|
||
tl.write({
|
||
'billed_hrs': billed_hrs or 0,
|
||
'billed_min': billed_min or 0,
|
||
'billed_sec': billed_sec or 0,
|
||
'product_id': product_id or False,
|
||
'notes': notes or '',
|
||
'state': 'reconciled',
|
||
})
|
||
result = {'state': 'reconciled', 'timer_id': tl.id}
|
||
if start_new:
|
||
new = self._do_labor_timer_start(tl.step_id.id)
|
||
result['new_timer_id'] = new['timer_id']
|
||
return result
|
||
|
||
# Need fields import for fields.Datetime.now() above
|
||
```
|
||
|
||
Add `from odoo import fields` at the top of the file alongside the other imports.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/controllers/move_controller.py
|
||
git commit -m "feat(sub12b): persistent labor timer endpoints
|
||
|
||
5 routes wrap fp.job.step.timelog state machine:
|
||
/fp/tablet/labor_timer/start → starts new running timer; auto-
|
||
pauses any other running timer
|
||
owned by this operator
|
||
/fp/tablet/labor_timer/pause → flip running → paused, stamp
|
||
last_paused_at
|
||
/fp/tablet/labor_timer/resume → accumulate paused duration into
|
||
total_paused_seconds, flip back
|
||
to running
|
||
/fp/tablet/labor_timer/stop → pre-fill billed_* from accrued,
|
||
state → stopped (Stop Timer
|
||
dialog opens client-side)
|
||
/fp/tablet/labor_timer/reconcile → save edited billed_* + optional
|
||
product_id, state → reconciled.
|
||
If start_new=True, immediately
|
||
start a new running timer on
|
||
the same step.
|
||
|
||
Mirrors screen 10 — Cancel / Save / Save & Start New Timer.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: OWL — Move Parts dialog (component + template)
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating_shopfloor/static/src/js/move_parts_dialog.js`
|
||
- Create: `fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml`
|
||
|
||
- [ ] **Step 1: Create the JS component**
|
||
|
||
```javascript
|
||
/** @odoo-module */
|
||
|
||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||
import { Dialog } from "@web/core/dialog/dialog";
|
||
import { rpc } from "@web/core/network/rpc";
|
||
import { useService } from "@web/core/utils/hooks";
|
||
import { _t } from "@web/core/l10n/translation";
|
||
|
||
export class FpMovePartsDialog extends Component {
|
||
static template = "fusion_plating_shopfloor.FpMovePartsDialog";
|
||
static components = { Dialog };
|
||
static props = ["fromStepId", "toStepId", "onCommit", "close"];
|
||
|
||
setup() {
|
||
this.notification = useService("notification");
|
||
this.state = useState({
|
||
loading: true,
|
||
qty: 0,
|
||
qtyAvailable: 0,
|
||
fromStep: {},
|
||
toStep: {},
|
||
transferType: "step",
|
||
toTankId: false,
|
||
toLocation: "global",
|
||
customerWoCount: 0,
|
||
transitionPrompts: [],
|
||
promptValues: {},
|
||
blockers: [],
|
||
committing: false,
|
||
});
|
||
onWillStart(async () => {
|
||
await this.loadPreview();
|
||
});
|
||
}
|
||
|
||
async loadPreview() {
|
||
this.state.loading = true;
|
||
const data = await rpc("/fp/tablet/move_parts/preview", {
|
||
from_step_id: this.props.fromStepId,
|
||
to_step_id: this.props.toStepId,
|
||
});
|
||
if (!data.ok) {
|
||
this.notification.add(data.error, { type: "danger" });
|
||
this.props.close();
|
||
return;
|
||
}
|
||
this.state.qtyAvailable = data.qty_available;
|
||
this.state.qty = data.qty_available;
|
||
this.state.fromStep = data.from_step;
|
||
this.state.toStep = data.to_step;
|
||
this.state.transitionPrompts = data.transition_prompts;
|
||
this.state.blockers = data.blockers;
|
||
this.state.toTankId = (data.to_step.tank_options[0] || {}).id || false;
|
||
this.state.loading = false;
|
||
}
|
||
|
||
get hardBlocked() {
|
||
return this.state.blockers.some((b) => b.severity === "hard");
|
||
}
|
||
|
||
get requiredPromptsBlank() {
|
||
return this.state.transitionPrompts.some((p) => {
|
||
return p.required && !this.state.promptValues[p.id];
|
||
});
|
||
}
|
||
|
||
get canCommit() {
|
||
return !this.state.committing &&
|
||
!this.hardBlocked &&
|
||
!this.requiredPromptsBlank &&
|
||
this.state.qty > 0 &&
|
||
this.state.qty <= this.state.qtyAvailable;
|
||
}
|
||
|
||
async onCommit() {
|
||
if (!this.canCommit) return;
|
||
this.state.committing = true;
|
||
const result = await rpc("/fp/tablet/move_parts/commit", {
|
||
from_step_id: this.props.fromStepId,
|
||
to_step_id: this.props.toStepId,
|
||
qty: this.state.qty,
|
||
transfer_type: this.state.transferType,
|
||
to_tank_id: this.state.toTankId || false,
|
||
to_location: this.state.toLocation,
|
||
customer_wo_count: this.state.customerWoCount,
|
||
prompt_values: this.state.promptValues,
|
||
});
|
||
if (result.ok) {
|
||
this.notification.add(
|
||
_t("Move %s committed", result.move_name),
|
||
{ type: "success" },
|
||
);
|
||
if (this.props.onCommit) {
|
||
this.props.onCommit(result);
|
||
}
|
||
this.props.close();
|
||
} else {
|
||
this.notification.add(result.error || _t("Move failed"),
|
||
{ type: "danger" });
|
||
this.state.committing = false;
|
||
}
|
||
}
|
||
|
||
onResolveBlocker(blocker) {
|
||
// Sub 12b — resolution buttons. For Sub 12b initial scope, the
|
||
// rack_required path is the only one with a UI handler; the
|
||
// others log and inform the operator to escalate.
|
||
if (blocker.resolve_action === "open_rack_parts_dialog") {
|
||
// Parent component (tablet) listens for this and opens
|
||
// the Rack Parts sub-dialog. Simplest path: fire a custom
|
||
// event that the parent catches.
|
||
const ev = new CustomEvent("fp-resolve-rack", {
|
||
detail: { fromStepId: this.props.fromStepId },
|
||
});
|
||
window.dispatchEvent(ev);
|
||
this.props.close();
|
||
} else {
|
||
this.notification.add(blocker.message, { type: "warning" });
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the OWL template**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<templates xml:space="preserve">
|
||
|
||
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
||
<Dialog title.translate="Move Parts" size="'lg'">
|
||
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||
<div class="o_fp_move_field">
|
||
<label>Part Count</label>
|
||
<input type="number" t-model.number="state.qty"
|
||
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
||
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>From Node</label>
|
||
<span t-esc="state.fromStep.name"/>
|
||
</div>
|
||
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
||
<label>From Station</label>
|
||
<span t-esc="state.fromStep.tank_name"/>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Transfer Type</label>
|
||
<select t-model="state.transferType">
|
||
<option value="step">Step</option>
|
||
<option value="hold">Hold</option>
|
||
<option value="scrap">Scrap</option>
|
||
<option value="rework">Rework</option>
|
||
<option value="split">Split</option>
|
||
<option value="return">Return</option>
|
||
</select>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>To Node</label>
|
||
<span t-esc="state.toStep.name"/>
|
||
</div>
|
||
<div class="o_fp_move_field"
|
||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||
<label>To Station</label>
|
||
<select t-model.number="state.toTankId">
|
||
<t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
|
||
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||
</t>
|
||
</select>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>To Location</label>
|
||
<select t-model="state.toLocation">
|
||
<option value="global">Global</option>
|
||
<option value="quarantine">Quarantine</option>
|
||
<option value="staging_a">Staging A</option>
|
||
<option value="staging_b">Staging B</option>
|
||
<option value="shipping_dock">Shipping Dock</option>
|
||
<option value="scrap_bin">Scrap Bin</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="o_fp_compliance_prompts"
|
||
t-if="state.transitionPrompts.length">
|
||
<h5>Compliance Prompts</h5>
|
||
<div class="o_fp_prompt_row"
|
||
t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||
<label>
|
||
<t t-esc="p.name"/>
|
||
<span t-if="p.required" class="text-danger">*</span>
|
||
</label>
|
||
<input t-if="['text','customer_wo','time_hms','location_picker','signature','photo'].includes(p.input_type)"
|
||
type="text" t-model="state.promptValues[p.id]"/>
|
||
<input t-elif="['number','temperature','thickness','time_seconds'].includes(p.input_type)"
|
||
type="number" t-model.number="state.promptValues[p.id]"/>
|
||
<input t-elif="p.input_type === 'boolean' or p.input_type === 'pass_fail'"
|
||
type="checkbox" t-model="state.promptValues[p.id]"/>
|
||
<input t-elif="p.input_type === 'date'"
|
||
type="datetime-local" t-model="state.promptValues[p.id]"/>
|
||
<select t-elif="p.input_type === 'selection'"
|
||
t-model="state.promptValues[p.id]">
|
||
<option value="">— Select —</option>
|
||
<t t-foreach="p.selection_options.split(',')"
|
||
t-as="opt" t-key="opt">
|
||
<option t-att-value="opt.trim()"><t t-esc="opt.trim()"/></option>
|
||
</t>
|
||
</select>
|
||
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="o_fp_blockers" t-if="state.blockers.length">
|
||
<h5>Blockers</h5>
|
||
<div class="o_fp_blocker_row"
|
||
t-foreach="state.blockers" t-as="b" t-key="b_index"
|
||
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||
<span class="o_fp_blocker_icon">⚠</span>
|
||
<span class="o_fp_blocker_msg" t-esc="b.message"/>
|
||
<button t-if="b.resolve_action"
|
||
class="btn btn-sm btn-warning"
|
||
t-on-click="() => this.onResolveBlocker(b)">
|
||
Resolve
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div t-if="state.loading">Loading…</div>
|
||
|
||
<t t-set-slot="footer">
|
||
<button class="btn btn-secondary" t-on-click="props.close">
|
||
Cancel
|
||
</button>
|
||
<button class="btn btn-primary"
|
||
t-att-disabled="!canCommit"
|
||
t-on-click="onCommit">
|
||
MOVE (<t t-esc="state.qty"/>)
|
||
</button>
|
||
</t>
|
||
</Dialog>
|
||
</t>
|
||
|
||
</templates>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/static/src/js/move_parts_dialog.js \
|
||
fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml
|
||
git commit -m "feat(sub12b): OWL Move Parts dialog component + template
|
||
|
||
Mirrors Steelhead screens 1-3, 14-15. Loads preview on mount,
|
||
re-checks hard-blockers on commit. MOVE (n) button disabled when
|
||
hard-blocked OR required prompt blank — improvement over Steelhead's
|
||
silent disabled state.
|
||
|
||
Inline 'Resolve' button next to each blocker with a resolve_action.
|
||
For rack-required, fires a window CustomEvent ('fp-resolve-rack')
|
||
that the parent tablet catches to open the Rack Parts sub-dialog.
|
||
|
||
Typed input rendering by input_type — text/number/checkbox/select/
|
||
datetime, plus support for time_hms and signature/photo (text input
|
||
for now; full upload widget in Sub 12c).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: OWL — Rack Parts sub-dialog
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`
|
||
- Create: `fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml`
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```javascript
|
||
/** @odoo-module */
|
||
|
||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||
import { Dialog } from "@web/core/dialog/dialog";
|
||
import { rpc } from "@web/core/network/rpc";
|
||
import { useService } from "@web/core/utils/hooks";
|
||
import { _t } from "@web/core/l10n/translation";
|
||
|
||
export class FpRackPartsDialog extends Component {
|
||
static template = "fusion_plating_shopfloor.FpRackPartsDialog";
|
||
static components = { Dialog };
|
||
static props = ["fromStepId", "qty", "onRacked", "close"];
|
||
|
||
setup() {
|
||
this.notification = useService("notification");
|
||
this.state = useState({
|
||
racks: [],
|
||
search: "",
|
||
selectedRackId: false,
|
||
unit: "Count",
|
||
amount: this.props.qty,
|
||
saving: false,
|
||
});
|
||
onWillStart(async () => {
|
||
await this.refreshRacks("");
|
||
});
|
||
}
|
||
|
||
async refreshRacks(query) {
|
||
const data = await rpc("/fp/tablet/rack/list_empty", { query });
|
||
if (data.ok) {
|
||
this.state.racks = data.racks;
|
||
}
|
||
}
|
||
|
||
async onSearch(ev) {
|
||
this.state.search = ev.target.value;
|
||
await this.refreshRacks(this.state.search);
|
||
}
|
||
|
||
async onScan() {
|
||
const code = window.prompt(_t("Scan or type FP-RACK:<code>:"));
|
||
if (!code) return;
|
||
const data = await rpc("/fp/tablet/rack/scan_qr", { qr_code: code });
|
||
if (data.ok) {
|
||
this.state.selectedRackId = data.rack_id;
|
||
this.notification.add(
|
||
_t("Selected %s", data.rack_name), { type: "success" });
|
||
} else {
|
||
this.notification.add(data.error, { type: "danger" });
|
||
}
|
||
}
|
||
|
||
async onSave(printAfter) {
|
||
if (!this.state.selectedRackId) return;
|
||
this.state.saving = true;
|
||
const result = await rpc("/fp/tablet/rack_parts/commit", {
|
||
from_step_id: this.props.fromStepId,
|
||
rack_id: this.state.selectedRackId,
|
||
qty: this.state.amount,
|
||
});
|
||
if (result.ok) {
|
||
this.notification.add(
|
||
_t("Racked onto %s", result.rack_name),
|
||
{ type: "success" });
|
||
if (this.props.onRacked) {
|
||
this.props.onRacked(result);
|
||
}
|
||
this.props.close();
|
||
if (printAfter) {
|
||
window.open(`/web/report/pdf/fp.rack.travel/${this.state.selectedRackId}`, "_blank");
|
||
}
|
||
} else {
|
||
this.notification.add(result.error, { type: "danger" });
|
||
this.state.saving = false;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the template**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<templates xml:space="preserve">
|
||
|
||
<t t-name="fusion_plating_shopfloor.FpRackPartsDialog">
|
||
<Dialog title.translate="Rack Parts" size="'md'">
|
||
<div class="o_fp_rack_dialog">
|
||
<div class="o_fp_move_field">
|
||
<label>To Rack</label>
|
||
<input type="text" placeholder="Search racks…"
|
||
t-on-input="onSearch" t-att-value="state.search"/>
|
||
<button class="btn btn-sm btn-secondary"
|
||
t-on-click="onScan">
|
||
QR Scan
|
||
</button>
|
||
<select t-model.number="state.selectedRackId">
|
||
<option value="">— Select —</option>
|
||
<t t-foreach="state.racks" t-as="r" t-key="r.id">
|
||
<option t-att-value="r.id">
|
||
<t t-esc="r.name"/> (<t t-esc="r.code"/>)
|
||
</option>
|
||
</t>
|
||
</select>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Unit</label>
|
||
<select t-model="state.unit">
|
||
<option value="Count">Count</option>
|
||
<option value="Pieces">Pieces</option>
|
||
<option value="Lbs">Lbs</option>
|
||
<option value="Kg">Kg</option>
|
||
</select>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Amount</label>
|
||
<input type="number" t-model.number="state.amount"/>
|
||
</div>
|
||
</div>
|
||
|
||
<t t-set-slot="footer">
|
||
<button class="btn btn-secondary" t-on-click="props.close">
|
||
Cancel
|
||
</button>
|
||
<button class="btn btn-primary"
|
||
t-att-disabled="!state.selectedRackId or state.saving"
|
||
t-on-click="() => this.onSave(false)">
|
||
Save
|
||
</button>
|
||
<button class="btn btn-warning"
|
||
t-att-disabled="!state.selectedRackId or state.saving"
|
||
t-on-click="() => this.onSave(true)">
|
||
Save + Print
|
||
</button>
|
||
</t>
|
||
</Dialog>
|
||
</t>
|
||
|
||
</templates>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js \
|
||
fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml
|
||
git commit -m "feat(sub12b): OWL Rack Parts sub-dialog (mirrors screens 7-8)
|
||
|
||
Searchable empty-rack picker, QR-scan input, Unit (Count/Pieces/Lbs/
|
||
Kg) + Amount fields. Save commits the racking; Save + Print also
|
||
opens the rack travel ticket PDF in a new tab.
|
||
|
||
Note: the rack travel report (/web/report/pdf/fp.rack.travel/<id>)
|
||
is wired here but the actual report template lands in Sub 12c. For
|
||
Sub 12b shipping, Save + Print works once Sub 12c lands; until then
|
||
operators use plain Save.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: OWL — Move Rack dialog + Stop Timer dialog
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating_shopfloor/static/src/js/move_rack_dialog.js`
|
||
- Create: `fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml`
|
||
- Create: `fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js`
|
||
- Create: `fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml`
|
||
|
||
- [ ] **Step 1: Move Rack JS** — same shape as Move Parts but no transition prompts (rack moves don't show per-step prompts; they're rack-level). Render rack name in title, parts list (read-only), Type + To Node + To Station picker, billed labor block per batch.
|
||
|
||
```javascript
|
||
/** @odoo-module */
|
||
|
||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||
import { Dialog } from "@web/core/dialog/dialog";
|
||
import { rpc } from "@web/core/network/rpc";
|
||
import { useService } from "@web/core/utils/hooks";
|
||
import { _t } from "@web/core/l10n/translation";
|
||
|
||
export class FpMoveRackDialog extends Component {
|
||
static template = "fusion_plating_shopfloor.FpMoveRackDialog";
|
||
static components = { Dialog };
|
||
static props = ["rackId", "toStepId", "onCommit", "close"];
|
||
|
||
setup() {
|
||
this.notification = useService("notification");
|
||
this.state = useState({
|
||
loading: true,
|
||
rack: {},
|
||
batches: [],
|
||
toStep: {},
|
||
transferType: "step",
|
||
toTankId: false,
|
||
saving: false,
|
||
});
|
||
onWillStart(async () => {
|
||
const data = await rpc("/fp/tablet/move_rack/preview", {
|
||
rack_id: this.props.rackId,
|
||
to_step_id: this.props.toStepId,
|
||
});
|
||
if (!data.ok) {
|
||
this.notification.add(data.error, { type: "danger" });
|
||
this.props.close();
|
||
return;
|
||
}
|
||
this.state.rack = data.rack;
|
||
this.state.batches = data.batches;
|
||
this.state.toStep = data.to_step;
|
||
this.state.toTankId = (data.to_step.tank_options[0] || {}).id || false;
|
||
this.state.loading = false;
|
||
});
|
||
}
|
||
|
||
async onSave() {
|
||
this.state.saving = true;
|
||
const result = await rpc("/fp/tablet/move_rack/commit", {
|
||
rack_id: this.props.rackId,
|
||
to_step_id: this.props.toStepId,
|
||
transfer_type: this.state.transferType,
|
||
to_tank_id: this.state.toTankId || false,
|
||
});
|
||
if (result.ok) {
|
||
this.notification.add(
|
||
_t("Moved %s batches", result.count),
|
||
{ type: "success" });
|
||
if (this.props.onCommit) {
|
||
this.props.onCommit(result);
|
||
}
|
||
this.props.close();
|
||
} else {
|
||
this.notification.add(result.error, { type: "danger" });
|
||
this.state.saving = false;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<templates xml:space="preserve">
|
||
|
||
<t t-name="fusion_plating_shopfloor.FpMoveRackDialog">
|
||
<Dialog t-att-title="'Move Rack: ' + (state.rack.name || '')" size="'lg'">
|
||
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||
<div class="o_fp_move_field">
|
||
<label>Rack Tags</label>
|
||
<span t-foreach="state.rack.tag_ids" t-as="t" t-key="t.id"
|
||
class="o_fp_rack_tag_chip"
|
||
t-att-data-color="t.color">
|
||
<t t-esc="t.name"/>
|
||
</span>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Parts</label>
|
||
<ul>
|
||
<t t-foreach="state.batches" t-as="b" t-key="b.step_id">
|
||
<li>
|
||
<t t-esc="b.qty"/> <t t-esc="b.part_number"/>
|
||
on WO <t t-esc="b.wo_number"/>
|
||
</li>
|
||
</t>
|
||
</ul>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Type</label>
|
||
<select t-model="state.transferType">
|
||
<option value="step">Step</option>
|
||
<option value="hold">Hold</option>
|
||
<option value="rework">Rework</option>
|
||
</select>
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>To Node</label>
|
||
<span t-esc="state.toStep.name"/>
|
||
</div>
|
||
<div class="o_fp_move_field"
|
||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||
<label>To Station</label>
|
||
<select t-model.number="state.toTankId">
|
||
<t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
|
||
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||
</t>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div t-if="state.loading">Loading…</div>
|
||
|
||
<t t-set-slot="footer">
|
||
<button class="btn btn-secondary" t-on-click="props.close">
|
||
Cancel
|
||
</button>
|
||
<button class="btn btn-primary"
|
||
t-att-disabled="state.saving"
|
||
t-on-click="onSave">
|
||
Save
|
||
</button>
|
||
</t>
|
||
</Dialog>
|
||
</t>
|
||
|
||
</templates>
|
||
```
|
||
|
||
- [ ] **Step 2: Stop Timer JS**
|
||
|
||
```javascript
|
||
/** @odoo-module */
|
||
|
||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||
import { Dialog } from "@web/core/dialog/dialog";
|
||
import { rpc } from "@web/core/network/rpc";
|
||
import { useService } from "@web/core/utils/hooks";
|
||
import { _t } from "@web/core/l10n/translation";
|
||
|
||
export class FpStopTimerDialog extends Component {
|
||
static template = "fusion_plating_shopfloor.FpStopTimerDialog";
|
||
static components = { Dialog };
|
||
static props = ["timerId", "onReconciled", "close"];
|
||
|
||
setup() {
|
||
this.notification = useService("notification");
|
||
this.state = useState({
|
||
loading: true,
|
||
accruedSeconds: 0,
|
||
billedHrs: 0,
|
||
billedMin: 0,
|
||
billedSec: 0,
|
||
productId: false,
|
||
notes: "",
|
||
saving: false,
|
||
});
|
||
onWillStart(async () => {
|
||
// Stop the timer (transitions running/paused → stopped).
|
||
const data = await rpc("/fp/tablet/labor_timer/stop",
|
||
{ timer_id: this.props.timerId });
|
||
if (data.ok) {
|
||
this.state.accruedSeconds = data.accrued_seconds;
|
||
this.state.billedHrs = data.billed_hrs;
|
||
this.state.billedMin = data.billed_min;
|
||
this.state.billedSec = data.billed_sec;
|
||
this.state.loading = false;
|
||
} else {
|
||
this.notification.add(data.error, { type: "danger" });
|
||
this.props.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
get billedPct() {
|
||
const total = this.state.billedHrs * 3600
|
||
+ this.state.billedMin * 60
|
||
+ this.state.billedSec;
|
||
if (!this.state.accruedSeconds) return 0;
|
||
return Math.round(100 * total / this.state.accruedSeconds);
|
||
}
|
||
|
||
async commit(startNew) {
|
||
this.state.saving = true;
|
||
const result = await rpc("/fp/tablet/labor_timer/reconcile", {
|
||
timer_id: this.props.timerId,
|
||
billed_hrs: this.state.billedHrs,
|
||
billed_min: this.state.billedMin,
|
||
billed_sec: this.state.billedSec,
|
||
product_id: this.state.productId || false,
|
||
notes: this.state.notes,
|
||
start_new: startNew,
|
||
});
|
||
if (result.ok) {
|
||
this.notification.add(_t("Timer reconciled"), { type: "success" });
|
||
if (this.props.onReconciled) {
|
||
this.props.onReconciled(result);
|
||
}
|
||
this.props.close();
|
||
} else {
|
||
this.notification.add(result.error, { type: "danger" });
|
||
this.state.saving = false;
|
||
}
|
||
}
|
||
|
||
onSave() { return this.commit(false); }
|
||
onSaveAndStartNew() { return this.commit(true); }
|
||
}
|
||
```
|
||
|
||
`fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<templates xml:space="preserve">
|
||
|
||
<t t-name="fusion_plating_shopfloor.FpStopTimerDialog">
|
||
<Dialog title.translate="Stop User Labor Timer" size="'md'">
|
||
<div class="o_fp_timer_dialog" t-if="!state.loading">
|
||
<div class="o_fp_timer_summary">
|
||
Accrued: <t t-esc="state.accruedSeconds"/> sec
|
||
· Billed: <t t-esc="billedPct"/>%
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Billed Time</label>
|
||
<input type="number" t-model.number="state.billedHrs"
|
||
min="0"/> hrs
|
||
<input type="number" t-model.number="state.billedMin"
|
||
min="0" max="59"/> min
|
||
<input type="number" t-model.number="state.billedSec"
|
||
min="0" max="59"/> sec
|
||
</div>
|
||
<div class="o_fp_move_field">
|
||
<label>Notes</label>
|
||
<textarea t-model="state.notes" rows="2"/>
|
||
</div>
|
||
</div>
|
||
<div t-if="state.loading">Loading…</div>
|
||
|
||
<t t-set-slot="footer">
|
||
<button class="btn btn-secondary" t-on-click="props.close">
|
||
Cancel
|
||
</button>
|
||
<button class="btn btn-primary"
|
||
t-att-disabled="state.saving"
|
||
t-on-click="onSave">
|
||
Save
|
||
</button>
|
||
<button class="btn btn-warning"
|
||
t-att-disabled="state.saving"
|
||
t-on-click="onSaveAndStartNew">
|
||
Save & Start New Timer
|
||
</button>
|
||
</t>
|
||
</Dialog>
|
||
</t>
|
||
|
||
</templates>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/static/src/js/move_rack_dialog.js \
|
||
fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml \
|
||
fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js \
|
||
fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml
|
||
git commit -m "feat(sub12b): Move Rack + Stop Timer OWL dialogs
|
||
|
||
Move Rack: rack name in title, tag chips, batches list (read-only),
|
||
Type + To Node + To Station picker, atomic Save commits all batches.
|
||
|
||
Stop Timer: opens with state already at 'stopped' (server flipped on
|
||
load), pre-fills billed_* from accrued. Operator edits → Save (state
|
||
→ reconciled). Save & Start New Timer chains into a fresh timer for
|
||
the same step (mirrors screen 10's right-most button).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Shared SCSS for the dialogs
|
||
|
||
**Files:**
|
||
- Create: `fusion_plating_shopfloor/static/src/scss/move_dialogs.scss`
|
||
|
||
- [ ] **Step 1: Create the SCSS**
|
||
|
||
```scss
|
||
// Sub 12b — shared SCSS for Move Parts / Move Rack / Rack Parts /
|
||
// Stop Timer dialogs. Tokens follow fp-shopfloor pattern with
|
||
// dark-mode SCSS @if branch.
|
||
|
||
$o-webclient-color-scheme: bright !default;
|
||
|
||
$_fp_md_card_hex: #ffffff;
|
||
$_fp_md_border_hex: #d8dadd;
|
||
$_fp_md_accent_hex: #2e7d6b;
|
||
$_fp_md_muted_hex: #6b7280;
|
||
$_fp_md_warn_hex: #fff3cd;
|
||
$_fp_md_danger_hex: #f8d7da;
|
||
|
||
@if $o-webclient-color-scheme == dark {
|
||
$_fp_md_card_hex: #22262d !global;
|
||
$_fp_md_border_hex: #3a3f47 !global;
|
||
$_fp_md_warn_hex: #4a3a1a !global;
|
||
$_fp_md_danger_hex: #4a1f1f !global;
|
||
}
|
||
|
||
$fp-md-card: var(--fp-card-bg, #{$_fp_md_card_hex});
|
||
$fp-md-border: var(--fp-border-color, #{$_fp_md_border_hex});
|
||
$fp-md-accent: var(--fp-accent, #{$_fp_md_accent_hex});
|
||
$fp-md-muted: var(--fp-muted, #{$_fp_md_muted_hex});
|
||
$fp-md-warn: var(--fp-warn-bg, #{$_fp_md_warn_hex});
|
||
$fp-md-danger: var(--fp-danger-bg, #{$_fp_md_danger_hex});
|
||
|
||
.o_fp_move_dialog,
|
||
.o_fp_rack_dialog,
|
||
.o_fp_timer_dialog {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: .75rem;
|
||
|
||
.o_fp_move_field {
|
||
display: grid;
|
||
grid-template-columns: 9rem 1fr auto;
|
||
align-items: center;
|
||
gap: .5rem;
|
||
|
||
label {
|
||
font-weight: 500;
|
||
margin: 0;
|
||
}
|
||
|
||
input, select, textarea {
|
||
padding: .375rem .5rem;
|
||
border: 1px solid $fp-md-border;
|
||
border-radius: 4px;
|
||
background: $fp-md-card;
|
||
}
|
||
}
|
||
|
||
.o_fp_compliance_prompts,
|
||
.o_fp_blockers {
|
||
margin-top: .5rem;
|
||
padding: .75rem;
|
||
border: 1px solid $fp-md-border;
|
||
border-radius: 4px;
|
||
|
||
h5 {
|
||
margin: 0 0 .5rem 0;
|
||
color: $fp-md-accent;
|
||
font-size: .9rem;
|
||
}
|
||
}
|
||
|
||
.o_fp_blocker_row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .5rem;
|
||
padding: .5rem;
|
||
margin-bottom: .25rem;
|
||
border-radius: 4px;
|
||
|
||
&.o_fp_blocker_soft {
|
||
background: $fp-md-warn;
|
||
}
|
||
&.o_fp_blocker_hard {
|
||
background: $fp-md-danger;
|
||
}
|
||
|
||
.o_fp_blocker_icon {
|
||
font-size: 1.25rem;
|
||
}
|
||
.o_fp_blocker_msg {
|
||
flex: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
.o_fp_rack_tag_chip {
|
||
display: inline-block;
|
||
padding: .125rem .5rem;
|
||
margin-right: .25rem;
|
||
border-radius: 999px;
|
||
font-size: .75rem;
|
||
background: $fp-md-card;
|
||
border: 1px solid $fp-md-border;
|
||
}
|
||
|
||
.o_fp_timer_summary {
|
||
padding: .5rem;
|
||
background: var(--fp-page-bg, #f3f4f6);
|
||
border-radius: 4px;
|
||
color: $fp-md-muted;
|
||
font-size: .875rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/static/src/scss/move_dialogs.scss
|
||
git commit -m "feat(sub12b): shared SCSS for Move/Rack/Timer dialogs
|
||
|
||
3-column grid layout for field rows (label / input / hint).
|
||
Compliance prompts + Blockers blocks have their own backgrounds.
|
||
Soft blockers amber, hard blockers red — matches the spec's protocol.
|
||
|
||
Token pattern + dark-mode @if branch (CLAUDE.md rule).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Wire dialogs into the tablet + plant-overview UIs
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js`
|
||
- Modify: `fusion_plating_shopfloor/static/src/js/plant_overview.js`
|
||
- Modify: `fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml`
|
||
- Modify: `fusion_plating_shopfloor/static/src/xml/plant_overview.xml`
|
||
|
||
- [ ] **Step 1: Read existing tablet JS to find where to add the buttons**
|
||
|
||
```bash
|
||
grep -n "openMoveDialog\|move_parts\|move_rack\|onTimer\|stop_wo" fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js | head
|
||
```
|
||
|
||
- [ ] **Step 2: Add Move Parts / Stop Timer button handlers to `shopfloor_tablet.js`**
|
||
|
||
In the FpShopfloorTablet component (or whatever the existing class is called), add at the top alongside other imports:
|
||
|
||
```javascript
|
||
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||
import { FpStopTimerDialog } from "./stop_timer_dialog";
|
||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||
```
|
||
|
||
Inside `setup()`, listen for the rack-resolution custom event:
|
||
|
||
```javascript
|
||
window.addEventListener("fp-resolve-rack", (ev) => {
|
||
this.dialog.add(FpRackPartsDialog, {
|
||
fromStepId: ev.detail.fromStepId,
|
||
qty: 0,
|
||
onRacked: () => this.refreshTablet(),
|
||
});
|
||
});
|
||
```
|
||
|
||
Add new methods:
|
||
|
||
```javascript
|
||
openMovePartsDialog(fromStepId, toStepId) {
|
||
this.dialog.add(FpMovePartsDialog, {
|
||
fromStepId, toStepId,
|
||
onCommit: () => this.refreshTablet(),
|
||
});
|
||
}
|
||
|
||
openStopTimerDialog(timerId) {
|
||
this.dialog.add(FpStopTimerDialog, {
|
||
timerId,
|
||
onReconciled: () => this.refreshTablet(),
|
||
});
|
||
}
|
||
```
|
||
|
||
(`this.refreshTablet()` should be the existing reload method — read the file to find what it's called and adjust.)
|
||
|
||
- [ ] **Step 3: Add Move Rack handler to `plant_overview.js`**
|
||
|
||
```javascript
|
||
import { FpMoveRackDialog } from "./move_rack_dialog";
|
||
```
|
||
|
||
```javascript
|
||
openMoveRackDialog(rackId, toStepId) {
|
||
this.dialog.add(FpMoveRackDialog, {
|
||
rackId, toStepId,
|
||
onCommit: () => this.reload(),
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the buttons in the XML templates**
|
||
|
||
In `shopfloor_tablet.xml`, find the existing per-step row and add (next to existing Mark Done / Pause buttons):
|
||
|
||
```xml
|
||
<button class="btn btn-sm btn-primary"
|
||
t-on-click="() => this.openMovePartsDialog(step.id, step.next_step_id)"
|
||
t-att-disabled="step.is_racked"
|
||
t-att-title="step.is_racked ? 'Racked — use Move Rack' : ''">
|
||
Move Parts
|
||
</button>
|
||
```
|
||
|
||
For the timer pause button (existing), wrap to call openStopTimerDialog instead of the current direct-pause behavior:
|
||
|
||
```xml
|
||
<button class="btn btn-sm btn-warning"
|
||
t-on-click="() => this.openStopTimerDialog(step.active_timer_id)"
|
||
t-if="step.active_timer_id">
|
||
Stop Timer
|
||
</button>
|
||
```
|
||
|
||
In `plant_overview.xml`, find the rack section (or add one if there's no Racks pane yet — see Task 16). Add MOVE RACK button per row:
|
||
|
||
```xml
|
||
<button class="btn btn-sm btn-danger"
|
||
t-on-click="() => this.openMoveRackDialog(rack.id, rack.next_step_id)">
|
||
MOVE RACK
|
||
</button>
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js \
|
||
fusion_plating_shopfloor/static/src/js/plant_overview.js \
|
||
fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml \
|
||
fusion_plating_shopfloor/static/src/xml/plant_overview.xml
|
||
git commit -m "feat(sub12b): wire Move/Rack/Timer dialogs into tablet + plant overview
|
||
|
||
Tablet: Move Parts button per step row (greyed when is_racked).
|
||
Listens for the 'fp-resolve-rack' custom event from inside Move Parts
|
||
to spawn Rack Parts sub-dialog. Stop Timer button replaces the legacy
|
||
direct-pause action.
|
||
|
||
Plant overview: MOVE RACK button per rack row.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Plant overview — 2-pane layout (Racks / Parts)
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating_shopfloor/static/src/js/plant_overview.js`
|
||
- Modify: `fusion_plating_shopfloor/static/src/xml/plant_overview.xml`
|
||
- Modify: `fusion_plating_shopfloor/controllers/shopfloor_controller.py`
|
||
|
||
- [ ] **Step 1: Extend the existing `/fp/shopfloor/plant_overview` endpoint** to return racks alongside batches:
|
||
|
||
```python
|
||
# In the existing plant_overview method's return payload, add:
|
||
racks = request.env['fusion.plating.rack'].search([
|
||
('state', 'in', ('loaded', 'in_use', 'awaiting_unrack')),
|
||
])
|
||
result['racks'] = [
|
||
{
|
||
'id': r.id,
|
||
'name': r.name,
|
||
'state': r.state,
|
||
'tag_ids': [{'id': t.id, 'name': t.name, 'color': t.color}
|
||
for t in r.tag_ids],
|
||
'current_part_count': r.current_part_count,
|
||
'current_node_name': r.current_job_step_id.name or '',
|
||
'current_tank_code': r.current_tank_id.code or '',
|
||
}
|
||
for r in racks
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Render the Racks pane in plant_overview.xml**
|
||
|
||
Add ABOVE the existing batches/parts pane:
|
||
|
||
```xml
|
||
<div class="o_fp_racks_pane" t-if="state.racks and state.racks.length">
|
||
<div class="o_fp_racks_header">
|
||
<h3>Racks</h3>
|
||
<button class="btn btn-sm btn-warning"
|
||
t-on-click="onUnrackMultiple">
|
||
UNRACK MULTIPLE
|
||
</button>
|
||
</div>
|
||
<div class="o_fp_rack_row"
|
||
t-foreach="state.racks" t-as="rack" t-key="rack.id">
|
||
<span class="o_fp_rack_name"><t t-esc="rack.name"/></span>
|
||
<span class="o_fp_rack_count"><t t-esc="rack.current_part_count"/> parts</span>
|
||
<span class="o_fp_rack_breadcrumb">
|
||
<t t-esc="rack.current_node_name"/>
|
||
<t t-if="rack.current_tank_code"> / <t t-esc="rack.current_tank_code"/></t>
|
||
</span>
|
||
<span t-foreach="rack.tag_ids" t-as="tag" t-key="tag.id"
|
||
class="o_fp_rack_tag_chip">
|
||
<t t-esc="tag.name"/>
|
||
</span>
|
||
<button class="btn btn-sm btn-primary"
|
||
t-on-click="() => this.openMoveRackDialog(rack.id, rack.next_step_id)">
|
||
MOVE RACK
|
||
</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Add `onUnrackMultiple` method**
|
||
|
||
```javascript
|
||
async onUnrackMultiple() {
|
||
const selected = this.state.racks.filter((r) => r.selected);
|
||
if (!selected.length) {
|
||
this.notification.add(_t("Select racks to unrack first."),
|
||
{ type: "warning" });
|
||
return;
|
||
}
|
||
// Bulk unrack: simply flip rack state and clear current_rack_id
|
||
// on linked steps. This is best-effort; manager review of the
|
||
// unrack chain happens via the Move Log.
|
||
for (const r of selected) {
|
||
await rpc("/fp/tablet/rack_parts/commit", {
|
||
from_step_id: false, // skip step-side update
|
||
rack_id: r.id,
|
||
qty: 0,
|
||
});
|
||
}
|
||
await this.reload();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/controllers/shopfloor_controller.py \
|
||
fusion_plating_shopfloor/static/src/js/plant_overview.js \
|
||
fusion_plating_shopfloor/static/src/xml/plant_overview.xml
|
||
git commit -m "feat(sub12b): plant overview — Racks pane + UNRACK MULTIPLE
|
||
|
||
Top pane shows all racks in (loaded / in_use / awaiting_unrack)
|
||
state with tag chips, part count, current node breadcrumb, and a
|
||
MOVE RACK primary button per row. UNRACK MULTIPLE bulk action
|
||
clears selected racks (operator-driven; movements are still logged
|
||
via fp.job.step.move).
|
||
|
||
Plant-overview controller payload extended to include racks list
|
||
alongside the existing parts/batches.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Manager-bypass context flags
|
||
|
||
**Files:**
|
||
- Modify: `fusion_plating_shopfloor/controllers/move_controller.py`
|
||
|
||
- [ ] **Step 1: Add bypass-flag handling**
|
||
|
||
In `_blockers_for_move`, at the top:
|
||
|
||
```python
|
||
ctx = request.env.context
|
||
# Manager-bypass flags (per CLAUDE.md cross-cutting protocol)
|
||
skip_predecessor = ctx.get('fp_skip_predecessor_check') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||
skip_rack = ctx.get('fp_skip_rack_assignment') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||
skip_transition = ctx.get('fp_skip_transition_form') and request.env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||
```
|
||
|
||
Then guard the corresponding blockers behind these flags.
|
||
|
||
In `_do_move_parts_commit`, when a manager-bypass triggers, post to chatter:
|
||
|
||
```python
|
||
if skip_predecessor or skip_rack or skip_transition:
|
||
move.message_post(body=_(
|
||
"Manager bypass activated by %s — flags: predecessor=%s, "
|
||
"rack=%s, transition=%s"
|
||
) % (request.env.user.name, skip_predecessor, skip_rack, skip_transition))
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add fusion_plating_shopfloor/controllers/move_controller.py
|
||
git commit -m "feat(sub12b): manager-bypass context flags
|
||
|
||
Three new flags consistent with the existing fp_skip_* protocol
|
||
(CLAUDE.md cross-cutting):
|
||
fp_skip_predecessor_check → bypass S14 lock
|
||
fp_skip_rack_assignment → bypass requires_rack_assignment
|
||
fp_skip_transition_form → bypass required transition prompts
|
||
|
||
Manager-only (group check). All bypasses post to chatter on the
|
||
move record naming the user + which flags fired.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Deploy to entech + smoke test + push
|
||
|
||
**Files:**
|
||
- (none — deployment + manual verification)
|
||
|
||
- [ ] **Step 1: Tar and ship all Sub 12b files**
|
||
|
||
```bash
|
||
tar -cf - \
|
||
fusion_plating/__init__.py \
|
||
fusion_plating/__manifest__.py \
|
||
fusion_plating/models/__init__.py \
|
||
fusion_plating/models/fp_rack.py \
|
||
fusion_plating/models/fp_rack_tag.py \
|
||
fusion_plating/models/fp_job.py \
|
||
fusion_plating/models/fp_job_step.py \
|
||
fusion_plating/models/fp_job_step_timelog.py \
|
||
fusion_plating/models/fp_job_step_move.py \
|
||
fusion_plating/security/ir.model.access.csv \
|
||
fusion_plating/data/fp_sequence_data.xml \
|
||
fusion_plating/views/fp_rack_views.xml \
|
||
fusion_plating/views/fp_rack_tag_views.xml \
|
||
fusion_plating/views/fp_job_step_move_views.xml \
|
||
fusion_plating_shopfloor/__manifest__.py \
|
||
fusion_plating_shopfloor/controllers/__init__.py \
|
||
fusion_plating_shopfloor/controllers/move_controller.py \
|
||
fusion_plating_shopfloor/controllers/shopfloor_controller.py \
|
||
fusion_plating_shopfloor/static/src/js/move_parts_dialog.js \
|
||
fusion_plating_shopfloor/static/src/js/move_rack_dialog.js \
|
||
fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js \
|
||
fusion_plating_shopfloor/static/src/js/stop_timer_dialog.js \
|
||
fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js \
|
||
fusion_plating_shopfloor/static/src/js/plant_overview.js \
|
||
fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml \
|
||
fusion_plating_shopfloor/static/src/xml/move_rack_dialog.xml \
|
||
fusion_plating_shopfloor/static/src/xml/rack_parts_dialog.xml \
|
||
fusion_plating_shopfloor/static/src/xml/stop_timer_dialog.xml \
|
||
fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml \
|
||
fusion_plating_shopfloor/static/src/xml/plant_overview.xml \
|
||
fusion_plating_shopfloor/static/src/scss/move_dialogs.scss \
|
||
| ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar -xf -'"
|
||
```
|
||
|
||
- [ ] **Step 2: Update both modules**
|
||
|
||
```bash
|
||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
||
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
||
-u fusion_plating,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -25 && \
|
||
systemctl start odoo'"
|
||
```
|
||
|
||
Expected: clean upgrade (233 modules loaded, no errors).
|
||
|
||
- [ ] **Step 3: Clear asset cache**
|
||
|
||
```bash
|
||
ssh pve-worker5 "pct exec 111 -- bash -c \"su - postgres -c 'psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\\''/web/assets/%'\\'';\\\"'\""
|
||
```
|
||
|
||
- [ ] **Step 4: Manual smoke test on tablet** (browser, real entech data)
|
||
|
||
1. Open tablet station (Plating → Shop Floor → Tablet Station). Confirm the per-step row now shows a **Move Parts** button.
|
||
2. Tap Move Parts on an in-flight step → dialog opens, system fields pre-filled.
|
||
3. If the destination step has any author-defined transition prompts, confirm the Compliance Prompts section renders them. Try tapping MOVE with a required prompt blank → button is disabled, hover tooltip explains why.
|
||
4. Fill prompts, tap MOVE → notification "Move FP/MOVE/2026/00001 committed".
|
||
5. Verify Plating → Move Log shows the new row.
|
||
6. Move parts to a step where the destination has `requires_rack_assignment=True` (set this on a step template via Sub 12a's library form first if not already). Confirm amber blocker + RACK PARTS button.
|
||
7. Tap RACK PARTS → sub-dialog → pick rack → Save → blocker clears, MOVE re-enables.
|
||
8. Confirm tablet now shows Move Parts button greyed out for the racked batch.
|
||
9. On Plant Overview, confirm Racks pane appears with the loaded rack. Tap MOVE RACK → atomic multi-batch move.
|
||
10. Tap timer pause on a running step → Stop Timer dialog opens. Edit billed time, tap Save → state moves to reconciled. Try Save & Start New Timer to verify chaining works.
|
||
11. Run battle test `bt_s2_*` (existing) → confirm timer + step transitions still work end-to-end.
|
||
|
||
- [ ] **Step 5: Update CLAUDE.md sub-project status table**
|
||
|
||
Mark Sub 12b as **Shipped 2026-MM-DD** with the merge commit SHA.
|
||
|
||
- [ ] **Step 6: Commit + push**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "docs(sub12b): mark Sub 12b as shipped on entech"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### Spec coverage check
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| 5.2 fp.rack | Task 3 (extends existing) |
|
||
| 5.2 fp.rack.tag | Task 2 |
|
||
| 5.2 fp.job.step.move + child input value | Tasks 4, 5 |
|
||
| 5.2 fp.labor.timer | Task 7 (folded into existing fp.job.step.timelog with state machine) |
|
||
| 5.2 fp.job.step extensions | Task 6 |
|
||
| 5.2 fp.job extensions | Task 6 |
|
||
| 5.3 Move Parts dialog | Tasks 8, 11 |
|
||
| 5.4 Rack Parts sub-dialog | Tasks 9, 12 |
|
||
| 5.5 Move Rack dialog | Tasks 9, 13 |
|
||
| 5.6 Stop Timer dialog | Tasks 10, 13 |
|
||
| 5.7 Tablet runtime guards | Task 15 |
|
||
| 5.7 Plant overview 2-pane | Task 16 |
|
||
| 5.8 Backend controller endpoints | Tasks 8, 9, 10 |
|
||
| 5.9 Migration / install | Tasks 1, 2 (rack tag seed) |
|
||
| 5.10 Verification | Task 18 |
|
||
| 7.1 Manager-bypass flags | Task 17 |
|
||
|
||
All spec sections covered.
|
||
|
||
### Type / signature consistency
|
||
|
||
- `_blockers_for_move(from_step, to_step, qty)` defined Task 8, called by `move_parts_preview` (Task 8) and `_do_move_parts_commit` (Task 8) with same signature. ✓
|
||
- `_safe(method, *args, **kwargs)` defined Task 8, used by Tasks 8, 9, 10. ✓
|
||
- `_step_payload` defined Task 8, used by Move Parts preview + Move Rack preview (Task 9). ✓
|
||
- `_capture_prompt_value(move, node_input_id, value)` defined Task 8, called by `_do_move_parts_commit` (Task 8). ✓
|
||
- `fp.job.step.timelog.state` defined Task 7, used by Task 10's start/pause/resume/stop/reconcile endpoints + Task 6's `active_timer_ids` filtered O2M. ✓
|
||
- Custom event name `fp-resolve-rack` defined in Move Parts dialog (Task 11), listened to in tablet (Task 15). ✓
|
||
- Manager-bypass flag names `fp_skip_*` defined Task 17, consistent with existing flags in CLAUDE.md.
|
||
|
||
### Placeholder scan
|
||
|
||
No "TBD" / "TODO" / "implement later" / "fill in details" found.
|
||
|
||
One acknowledged gap: Task 12's `Save + Print` in Rack Parts dialog references `/web/report/pdf/fp.rack.travel/<id>` — the actual report ships in Sub 12c. Until then, Save + Print produces a 404. Documented in the task body.
|
||
|
||
---
|
||
|
||
**Plan complete. 18 tasks, ~3-4 days end-to-end.**
|