Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-25-fp-native-job-model.md
gsinghpal d1aa7a81e0 docs(jobs): detail Phase 2 task breakdown — parallel module strategy
Phase 2 was previously outlined as 'rename bridge_mrp → jobs'.
That's destructive on entech. Revised strategy: build
fusion_plating_jobs IN PARALLEL with bridge_mrp. A settings flag
(x_fc_use_native_jobs) controls which path SO confirm takes.
Default False = legacy MO flow stays. Cutover (Phase 9) flips
the flag.

Phase 2 breakdown into 11 tasks (2.1–2.11), totaling ~5 days
engineering. All preserve bridge_mrp untouched until cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:49:34 -04:00

1828 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Native Plating Job Model — 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:** Replace `mrp.production` and `mrp.workorder` with native `fp.job` and `fp.job.step` models across all Fusion Plating modules, with zero data loss on entech.
**Architecture:** New core models in `fusion_plating`. Refactor `fusion_plating_bridge_mrp``fusion_plating_jobs` (gut SO/MO/WO bridge, keep recipe-step-generator logic). Refactor 10 dependent modules to rebind from `mrp.production`/`mrp.workorder` to `fp.job`/`fp.job.step`. Migrate live entech data via post-migration script. Big-bang cutover with 2-week shadow period.
**Tech Stack:** Odoo 19, Python 3.12, PostgreSQL, OWL, XML views.
**Spec:** `docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`
---
## Why This Plan Exists
We bridged plating into Odoo MRP three months ago. The cost has been ~5,000 LOC of sync code and a UX where one job = N work orders. Decision §10 of the spec locks Path A: replace MRP with native models. This plan is the day-by-day execution.
---
## Phase Overview
Total estimated effort: **39 working days ≈ 8 weeks engineering, 911 weeks calendar**.
| # | Phase | Duration | Deliverable | Detailed tasks ready? |
|---|---|---|---|---|
| 1 | **Core models** | 3 days | `fp.job`, `fp.job.step`, `fp.job.step.timelog`, `fp.work.centre` models live in `fusion_plating` core. Sequences, security, basic admin views. | **Yes — see §Phase 1** |
| 2 | **`fusion_plating_jobs` (rename + gut bridge)** | 5 days | SO → job hook. Recipe → steps generator. All `x_fc_*` MO/WO fields migrated to native fields. | Outline only — detail when Phase 1 lands |
| 3 | **Light refactors batch A** (batch, quality, certificates, invoicing, logistics, portal, receiving) | 4 days | All these modules rebind their `production_id`/`workorder_id` fields to `job_id`/`step_id`. | Outline only |
| 4 | **Light refactors batch B** (configurator, notifications, KPI, aerospace/nuclear/cgp/safety) | 3 days | SO buttons rebound, notification triggers rebound, KPI queries updated. | Outline only |
| 5 | **Reports rewrite** | 3 days | WO sticker, job traveller, WO margin, BoL, packing slip, invoice rewrite against `fp.job`/`fp.job.step`. | Outline only |
| 6 | **Shopfloor rewrite** | 6 days | Plant Overview kanban, Tablet Station, Process Tree (now primary view), Manager Dashboard. New RPC routes. | Outline only |
| 7 | **Migration script** | 3 days | Idempotent script: `mrp.production``fp.job`; `mrp.workorder``fp.job.step`; chatter, attachments, certs, batches, holds rebound. Pre/post audit scripts. | Outline only |
| 8 | **Test on entech-clone** | 5 days | Restore entech DB → run migration → replay 30 days of activity → diff against pre-migration snapshot. Render 100 sample CoCs and byte-diff. | Outline only |
| 9 | **Cutover weekend** | 1 day (calendar) | Live cutover on entech. Pre-snapshot, deploy, migrate, smoke test, monitor. | Outline only |
| 10 | **Burn-in** | 2 weeks (calendar) | Daily monitoring. Forward-fix issues. After 2 weeks, drop MRP table snapshots. | Outline only |
**Branch strategy:**
- Single feature branch: `feat/fp-native-job-model`
- Merge to `main` only at cutover. No piecemeal merges (avoids dual-system mess in production).
- Daily commits to feature branch; phase milestones tagged: `phase-1-complete`, `phase-2-complete`, etc.
- Cursor (the user's other tool) must avoid this branch during the sprint.
**Cross-phase invariants:**
- Recipe template (`fusion.plating.process.node`) is **never** modified by any task in this plan. If a task changes that model, stop and re-read the spec.
- Customer-facing label `WO #00033` stays. Internal model name is `fp.job` but the UI never shows that string.
- Migrated records keep their `WH/MO/00033` name format. New records get `WH/JOB/00033`. Both work as scan targets.
---
## Phase 1: Core Models
**Goal:** Land the foundational models in `fusion_plating` (the core module). After this phase, the new models exist, can be created/written/queried via shell or admin views, and have basic ACL coverage. No business logic yet — that's Phase 2.
**Branch:** `feat/fp-native-job-model`
**Files this phase touches:**
- Create: `fusion_plating/models/fp_work_centre.py`
- Create: `fusion_plating/models/fp_job.py`
- Create: `fusion_plating/models/fp_job_step.py`
- Create: `fusion_plating/models/fp_job_step_timelog.py`
- Create: `fusion_plating/data/fp_job_sequences.xml`
- Create: `fusion_plating/views/fp_work_centre_views.xml`
- Create: `fusion_plating/views/fp_job_views.xml`
- Create: `fusion_plating/views/fp_job_step_views.xml`
- Create: `fusion_plating/views/fp_jobs_menu.xml`
- Modify: `fusion_plating/models/__init__.py` — add new model imports
- Modify: `fusion_plating/__manifest__.py` — bump version, register new data files
- Modify: `fusion_plating/security/ir.model.access.csv` — add ACLs
- Create: `fusion_plating/tests/test_fp_job_state_machine.py`
- Create: `fusion_plating/tests/test_fp_job_step_state_machine.py`
---
### Task 1.1: Create branch + install bare module on local dev
- [ ] **Step 1: Create feature branch**
```bash
cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating
git checkout -b feat/fp-native-job-model
```
- [ ] **Step 2: Verify clean state**
Run: `git status`
Expected: `On branch feat/fp-native-job-model` and "nothing to commit, working tree clean"
- [ ] **Step 3: Verify local dev container is healthy**
Run: `docker exec odoo-dev-app odoo --version`
Expected: prints Odoo 19 version string
- [ ] **Step 4: Commit branch baseline (no changes yet — sanity)**
No commit needed. Branch exists, ready for work.
---
### Task 1.2: Create `fp.work.centre` model
This replaces `mrp.workcenter` for plating. Domain-specific kinds (wet line / bake / mask / rack / inspect / other).
**File:** `fusion_plating/models/fp_work_centre.py`
- [ ] **Step 1: Write the failing test**
Create `fusion_plating/tests/test_fp_work_centre.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestFpWorkCentre(TransactionCase):
def test_create_work_centre_minimal(self):
wc = self.env['fp.work.centre'].create({
'name': 'Bath Line 1',
'code': 'BL1',
'kind': 'wet_line',
})
self.assertEqual(wc.name, 'Bath Line 1')
self.assertEqual(wc.kind, 'wet_line')
self.assertTrue(wc.active)
def test_facility_optional_at_create(self):
# Facility is soft-required (warning at confirm, not constraint
# at create) — verify a centre without facility still creates.
wc = self.env['fp.work.centre'].create({
'name': 'Test',
'code': 'T',
'kind': 'other',
})
self.assertFalse(wc.facility_id)
def test_kind_selection_values(self):
kinds = dict(
self.env['fp.work.centre']._fields['kind'].selection
)
for k in ('wet_line', 'bake', 'mask', 'rack', 'inspect', 'other'):
self.assertIn(k, kinds)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init -i fusion_plating 2>&1 | tail -20`
Expected: ImportError or "Model 'fp.work.centre' does not exist"
- [ ] **Step 3: Create the model**
Write `fusion_plating/models/fp_work_centre.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.work.centre — native plating work-centre model.
#
# Replaces mrp.workcenter for the plating flow. Plating work centres
# are domain-specific (a tank line, a bake oven, a rack station — not
# assembly cells). Each centre has a 'kind' that drives release-ready
# validation on fp.job.step (e.g. wet_line → bath+tank required).
from odoo import fields, models
class FpWorkCentre(models.Model):
_name = 'fp.work.centre'
_description = 'Plating Work Centre'
_order = 'sequence, code, name'
name = fields.Char(required=True)
code = fields.Char(required=True, help='Short code used on stickers and reports.')
sequence = fields.Integer(default=10)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
)
kind = fields.Selection(
[
('wet_line', 'Wet Line'),
('bake', 'Bake Oven'),
('mask', 'Masking'),
('rack', 'Racking'),
('inspect', 'Inspection'),
('other', 'Other'),
],
required=True,
default='other',
)
cost_per_hour = fields.Monetary(
currency_field='currency_id',
help='Used for fp.job.step cost rollups.',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
default_bath_id = fields.Many2one('fusion.plating.bath')
default_tank_id = fields.Many2one('fusion.plating.tank')
default_oven_id = fields.Many2one('fusion.plating.oven')
active = fields.Boolean(default=True)
_sql_constraints = [
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
]
```
- [ ] **Step 4: Register the model in `__init__.py`**
Modify `fusion_plating/models/__init__.py` — add line:
```python
from . import fp_work_centre
```
- [ ] **Step 5: Add ACL rows**
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
- [ ] **Step 6: Update manifest version + module load**
Modify `fusion_plating/__manifest__.py` — bump `version` from current to `19.0.<next>.0` (check current first), confirm new model gets loaded automatically via `__init__`.
- [ ] **Step 7: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 8: Run test to verify it passes**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All 3 tests pass.
- [ ] **Step 9: Commit**
```bash
git add fusion_plating/models/fp_work_centre.py \
fusion_plating/models/__init__.py \
fusion_plating/security/ir.model.access.csv \
fusion_plating/__manifest__.py \
fusion_plating/tests/test_fp_work_centre.py
git commit -m "feat(jobs): add fp.work.centre native model
Replaces mrp.workcenter for plating. Domain-specific kinds
(wet_line/bake/mask/rack/inspect/other) drive release-ready
validation on steps. ACLs follow existing user/supervisor/manager
hierarchy.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.3: Create `fp.job` model (header)
Replaces `mrp.production`. Header fields only this task; child step relations come in Task 1.5.
**File:** `fusion_plating/models/fp_job.py`
- [ ] **Step 1: Write the failing tests**
Create `fusion_plating/tests/test_fp_job_state_machine.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestFpJobStateMachine(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Customer'})
self.product = self.env['product.product'].create({'name': 'Widget'})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 10.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_create_lands_in_draft(self):
job = self._make_job()
self.assertEqual(job.state, 'draft')
self.assertTrue(job.name and job.name.startswith('WH/JOB/'))
def test_action_confirm_moves_to_confirmed(self):
job = self._make_job()
job.action_confirm()
self.assertEqual(job.state, 'confirmed')
def test_cannot_confirm_twice(self):
job = self._make_job()
job.action_confirm()
with self.assertRaises(UserError):
job.action_confirm()
def test_cancel_from_draft(self):
job = self._make_job()
job.action_cancel()
self.assertEqual(job.state, 'cancelled')
def test_cannot_confirm_after_cancel(self):
job = self._make_job()
job.action_cancel()
with self.assertRaises(UserError):
job.action_confirm()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | tail -20`
Expected: 5 tests fail with "Model 'fp.job' does not exist"
- [ ] **Step 3: Create the model**
Write `fusion_plating/models/fp_job.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job — native plating job model.
#
# Replaces mrp.production for plating. One record per shop-floor job.
# Header data lives here; per-operation detail on fp.job.step.
# Recipe template (fusion.plating.process.node) is unchanged — this
# model just instantiates from it via fp.job.step.recipe_node_id.
#
# State machine:
# draft → confirmed → in_progress → done
# ↓ ↑
# cancelled (rework reverts here)
# on_hold can be entered from confirmed or in_progress.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJob(models.Model):
_name = 'fp.job'
_description = 'Plating Job'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'priority desc, date_deadline asc, id desc'
_rec_name = 'name'
name = fields.Char(
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
index=True,
)
state = fields.Selection(
[
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('in_progress', 'In Progress'),
('on_hold', 'On Hold'),
('done', 'Done'),
('cancelled', 'Cancelled'),
],
default='draft',
required=True,
tracking=True,
index=True,
)
priority = fields.Selection(
[
('low', 'Low'),
('normal', 'Normal'),
('high', 'High'),
('rush', 'Rush'),
],
default='normal',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
tracking=True,
)
product_id = fields.Many2one('product.product', string='Reference Product')
qty = fields.Float(string='Quantity', required=True, default=1.0)
qty_done = fields.Float(string='Quantity Completed')
qty_scrapped = fields.Float(string='Quantity Scrapped')
date_deadline = fields.Datetime(string='Deadline', tracking=True)
date_planned_start = fields.Datetime(string='Planned Start')
date_started = fields.Datetime(string='Actual Start', readonly=True)
date_finished = fields.Datetime(string='Actual Finish', readonly=True)
origin = fields.Char(string='Source SO', help='Sale Order name for traceability.')
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
facility_id = fields.Many2one('fusion.plating.facility', string='Facility')
manager_id = fields.Many2one('res.users', string='Plating Manager')
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
required=True,
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
return super().create(vals_list)
def action_confirm(self):
for job in self:
if job.state != 'draft':
raise UserError(_(
"Job %s is in state '%s' — only draft jobs can be confirmed."
) % (job.name, job.state))
job.state = 'confirmed'
return True
def action_cancel(self):
for job in self:
if job.state == 'done':
raise UserError(_(
"Job %s is done — cannot cancel."
) % job.name)
job.state = 'cancelled'
return True
```
- [ ] **Step 4: Register the model in `__init__.py`**
Modify `fusion_plating/models/__init__.py` — append:
```python
from . import fp_job
```
- [ ] **Step 5: Add the sequence**
Create `fusion_plating/data/fp_job_sequences.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- noupdate="1" is REQUIRED — without it, every -u fusion_plating
resets number_next back to 1, which corrupts the live sequence
on every module update. Matches the convention in fp_sequence_data.xml. -->
<odoo noupdate="1">
<!-- Sequence for fp.job. Format: WH/JOB/00001 onwards.
Migrated mrp.production records keep their WH/MO/... names. -->
<record id="seq_fp_job" model="ir.sequence">
<field name="name">Plating Job Sequence</field>
<field name="code">fp.job</field>
<field name="prefix">WH/JOB/</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</odoo>
```
- [ ] **Step 6: Register sequence file in manifest**
Modify `fusion_plating/__manifest__.py` — add `'data/fp_job_sequences.xml'` to the `data` list (after existing data files, before view files).
- [ ] **Step 7: Add ACL rows**
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
- [ ] **Step 8: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 9: Run tests to verify they pass**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All 5 tests pass.
- [ ] **Step 10: Commit**
```bash
git add fusion_plating/models/fp_job.py \
fusion_plating/models/__init__.py \
fusion_plating/data/fp_job_sequences.xml \
fusion_plating/security/ir.model.access.csv \
fusion_plating/__manifest__.py \
fusion_plating/tests/test_fp_job_state_machine.py
git commit -m "feat(jobs): add fp.job native model with state machine
Header model replacing mrp.production. Mail thread for chatter,
priority/state/deadline tracking, sequence WH/JOB/00001+. Tests
cover create, confirm, cancel, and forbidden double-confirm.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.4: Add core-safe extension fields to `fp.job`
**Scope reduction (2026-04-25):** Originally this task added all spec §5.1 fields.
But the dependency audit during Task 1.4 implementation revealed that 6 of those
fields point to models in modules that depend on `fusion_plating` core (configurator,
quality, portal, logistics, bridge_mrp). Adding them in core would invert the
dependency graph. **Per the updated spec §5.1**, those fields are deferred to their
owning modules via `_inherit = 'fp.job'` and re-bundled by `fusion_plating_jobs` in
Phase 2.
This task now lands ONLY the fields whose target models are reachable from core's
existing `depends` (sale_management → sale → account, and our own process.node):
- [ ] **Step 1: Add SO + recipe core-safe fields**
Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`:
```python
sale_order_line_ids = fields.Many2many(
'sale.order.line',
'fp_job_sale_order_line_rel',
'job_id', 'line_id',
string='Source SO Lines',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain=[('node_type', '=', 'recipe')],
)
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='Rework: start the job at this recipe node (skip earlier).',
)
invoice_ids = fields.Many2many(
'account.move',
'fp_job_account_move_rel',
'job_id', 'move_id',
string='Invoices',
)
```
**Deferred to bridge modules (DO NOT add in this task):**
- `part_catalog_id`, `coating_config_id` → owned by `fusion_plating_configurator`
- `customer_spec_id` → owned by `fusion_plating_quality`
- `portal_job_id` → owned by `fusion_plating_portal`
- `delivery_id` → owned by `fusion_plating_logistics`
- `qc_check_id` → owned by `fusion_plating_jobs` (Phase 2; the underlying model
`fusion.plating.quality.check` currently lives in `fusion_plating_bridge_mrp`)
- [ ] **Step 2: Add cost rollup fields (computed)**
Append:
```python
quoted_revenue = fields.Monetary(
currency_field='currency_id',
help='From source SO.',
)
actual_cost = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin_pct = fields.Float(
compute='_compute_costs', store=True,
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
@api.depends('quoted_revenue') # step cost added in 1.5
def _compute_costs(self):
for job in self:
# Step time × rate rollup added in Task 1.5 once steps exist
job.actual_cost = 0.0
job.margin = job.quoted_revenue - job.actual_cost
job.margin_pct = (
(job.margin / job.quoted_revenue * 100.0)
if job.quoted_revenue else 0.0
)
```
- [ ] **Step 3: Add current_location computed field**
Append:
```python
current_location = fields.Char(
compute='_compute_current_location',
help='Human-readable: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship".',
)
def _compute_current_location(self):
# Full implementation lands in Task 1.6 once steps + work centres exist.
for job in self:
if job.state == 'draft':
job.current_location = 'Not started'
elif job.state == 'cancelled':
job.current_location = 'Cancelled'
elif job.state == 'done':
job.current_location = 'Done'
else:
job.current_location = job.state.replace('_', ' ').title()
```
- [ ] **Step 4: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 5: Run tests to verify they still pass**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All previous tests still pass; new fields don't break anything.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/models/fp_job.py
git commit -m "feat(jobs): add SO/recipe/portal/cost fields to fp.job
Extension fields covering source SO traceability, recipe link,
portal/delivery binding, cost rollup placeholders. Cost compute
is a stub until steps are added in Task 1.5.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.5: Create `fp.job.step` model
The per-operation model. Replaces `mrp.workorder`.
**File:** `fusion_plating/models/fp_job_step.py`
- [ ] **Step 1: Write the failing test**
Create `fusion_plating/tests/test_fp_job_step_state_machine.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestFpJobStepStateMachine(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
self.wc = self.env['fp.work.centre'].create({
'name': 'WC', 'code': 'WC', 'kind': 'wet_line',
})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
def _make_step(self, **kw):
vals = {
'job_id': self.job.id,
'name': 'Plating Bath',
'sequence': 10,
'work_centre_id': self.wc.id,
}
vals.update(kw)
return self.env['fp.job.step'].create(vals)
def test_step_starts_pending(self):
step = self._make_step()
self.assertEqual(step.state, 'pending')
def test_first_step_becomes_ready_on_job_confirm(self):
step = self._make_step()
self.job.action_confirm()
# Predecessor logic: step with no earlier sibling → ready
step._compute_state_ready()
self.assertIn(step.state, ('ready', 'pending')) # pending acceptable until 1.6
def test_button_start_moves_to_in_progress(self):
step = self._make_step()
step.state = 'ready'
step.button_start()
self.assertEqual(step.state, 'in_progress')
self.assertTrue(step.date_started)
def test_button_finish_requires_in_progress(self):
step = self._make_step()
with self.assertRaises(UserError):
step.button_finish() # state is pending
def test_button_finish_moves_to_done(self):
step = self._make_step()
step.state = 'ready'
step.button_start()
step.button_finish()
self.assertEqual(step.state, 'done')
self.assertTrue(step.date_finished)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | tail -20`
Expected: All 5 tests fail with "Model 'fp.job.step' does not exist"
- [ ] **Step 3: Create the model — minimal fields + state machine**
Write `fusion_plating/models/fp_job_step.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.step — one operation within a plating job.
#
# Replaces mrp.workorder. Each step instantiates from a recipe
# operation node (recipe_node_id). Container nodes (recipe,
# sub_process) and step nodes (instructions) are NOT rows here —
# they live on the recipe template and are used at view-render time
# to display hierarchy. See spec §5.2 (Option A — operations only).
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJobStep(models.Model):
_name = 'fp.job.step'
_description = 'Plating Job Step'
_inherit = ['mail.thread']
_order = 'job_id, sequence, id'
job_id = fields.Many2one(
'fp.job',
required=True,
ondelete='cascade',
index=True,
)
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
state = fields.Selection(
[
('pending', 'Pending'),
('ready', 'Ready'),
('in_progress', 'In Progress'),
('paused', 'Paused'),
('done', 'Done'),
('skipped', 'Skipped'),
('cancelled', 'Cancelled'),
],
default='pending',
required=True,
tracking=True,
index=True,
)
recipe_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Operation',
domain=[('node_type', '=', 'operation')],
)
work_centre_id = fields.Many2one('fp.work.centre', index=True)
kind = fields.Selection(
[
('wet', 'Wet'),
('bake', 'Bake'),
('mask', 'Mask'),
('rack', 'Rack'),
('inspect', 'Inspect'),
('other', 'Other'),
],
default='other',
)
assigned_user_id = fields.Many2one('res.users', tracking=True)
started_by_user_id = fields.Many2one('res.users', readonly=True)
finished_by_user_id = fields.Many2one('res.users', readonly=True)
date_started = fields.Datetime(readonly=True)
date_finished = fields.Datetime(readonly=True)
duration_expected = fields.Float(string='Expected Minutes')
duration_actual = fields.Float(string='Actual Minutes', readonly=True)
instructions = fields.Html(string='Step Instructions')
def button_start(self):
for step in self:
if step.state not in ('ready', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' — only ready/paused steps can start."
) % (step.name, step.state))
step.state = 'in_progress'
if not step.date_started:
step.date_started = fields.Datetime.now()
step.started_by_user_id = self.env.user
return True
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
step.state = 'done'
step.date_finished = fields.Datetime.now()
step.finished_by_user_id = self.env.user
return True
def _compute_state_ready(self):
# Stub: full predecessor logic in Task 1.6
for step in self:
if step.state == 'pending' and step.job_id.state in ('confirmed', 'in_progress'):
# First-sequence step in its job becomes ready
first = step.job_id.step_ids.sorted('sequence')[:1]
if first and first.id == step.id:
step.state = 'ready'
```
- [ ] **Step 4: Register the model**
Modify `fusion_plating/models/__init__.py` — append:
```python
from . import fp_job_step
```
- [ ] **Step 5: Add `step_ids` one2many on `fp.job`**
Modify `fusion_plating/models/fp_job.py` — add field after `qc_check_id`:
```python
step_ids = fields.One2many(
'fp.job.step',
'job_id',
string='Steps',
)
step_count = fields.Integer(compute='_compute_step_counts')
step_done_count = fields.Integer(compute='_compute_step_counts')
step_progress_pct = fields.Float(compute='_compute_step_counts')
current_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_current_step',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_step_counts(self):
for job in self:
job.step_count = len(job.step_ids)
job.step_done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
job.step_progress_pct = (
(job.step_done_count / job.step_count * 100.0)
if job.step_count else 0.0
)
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_current_step(self):
for job in self:
in_prog = job.step_ids.filtered(lambda s: s.state == 'in_progress')
if in_prog:
job.current_step_id = in_prog.sorted('sequence')[:1]
continue
ready = job.step_ids.filtered(lambda s: s.state == 'ready')
if ready:
job.current_step_id = ready.sorted('sequence')[:1]
continue
job.current_step_id = False
```
- [ ] **Step 6: Add ACL rows**
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
- [ ] **Step 7: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 8: Run tests to verify they pass**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All previous tests pass + 5 new step tests pass.
- [ ] **Step 9: Commit**
```bash
git add fusion_plating/models/fp_job_step.py \
fusion_plating/models/fp_job.py \
fusion_plating/models/__init__.py \
fusion_plating/security/ir.model.access.csv \
fusion_plating/tests/test_fp_job_step_state_machine.py
git commit -m "feat(jobs): add fp.job.step model with state machine
Per-operation model replacing mrp.workorder. Mirrors operations
from the recipe template (recipe_node_id link). 7-state machine:
pending → ready → in_progress → paused/done. Job header gets
step_ids + computed counts + current_step_id.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.6: Add equipment, audit, plating-spec fields to `fp.job.step`
The remaining fields from spec §5.2.
- [ ] **Step 1: Add equipment + audit fields**
Modify `fusion_plating/models/fp_job_step.py` — append fields:
```python
bath_id = fields.Many2one('fusion.plating.bath')
tank_id = fields.Many2one('fusion.plating.tank')
rack_id = fields.Many2one('fusion.plating.rack')
oven_id = fields.Many2one('fusion.plating.oven')
masking_material_id = fields.Many2one('fusion.plating.masking.material')
signoff_user_id = fields.Many2one('res.users', readonly=True)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='work_centre_id.facility_id',
store=True,
)
```
- [ ] **Step 2: Add plating-spec fields**
Append:
```python
thickness_target = fields.Float(string='Target Thickness')
thickness_uom = fields.Selection(
[('um', 'µm'), ('mil', 'mil'), ('inch', 'in')],
default='um',
)
dwell_time_minutes = fields.Float()
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
```
- [ ] **Step 3: Add recipe-related boolean fields**
Append:
```python
requires_signoff = fields.Boolean(
related='recipe_node_id.requires_signoff',
store=True,
)
auto_complete = fields.Boolean(
related='recipe_node_id.auto_complete',
store=True,
)
is_manual = fields.Boolean(
related='recipe_node_id.is_manual',
store=True,
)
customer_visible = fields.Boolean(
related='recipe_node_id.customer_visible',
store=True,
)
```
- [ ] **Step 4: Add cost computed field**
Append:
```python
cost_per_hour = fields.Monetary(
related='work_centre_id.cost_per_hour',
currency_field='currency_id',
)
cost_total = fields.Monetary(
compute='_compute_cost_total',
store=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
related='work_centre_id.currency_id',
)
@api.depends('duration_actual', 'cost_per_hour')
def _compute_cost_total(self):
for step in self:
step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour
```
- [ ] **Step 5: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 6: Run all tests**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All tests still pass.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/models/fp_job_step.py
git commit -m "feat(jobs): add equipment, audit, plating-spec fields to fp.job.step
Bath/tank/rack/oven/mask equipment links, sign-off audit user,
plating thickness target + UoM, bake parameters (Nadcap audit),
recipe-related booleans (requires_signoff, auto_complete,
is_manual, customer_visible) as related fields, cost rollup.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.7: Create `fp.job.step.timelog`
Granular start/stop tracking. Each pause creates a record.
**File:** `fusion_plating/models/fp_job_step_timelog.py`
- [ ] **Step 1: Write the failing test**
Append to `fusion_plating/tests/test_fp_job_step_state_machine.py`:
```python
class TestFpJobStepTimeLog(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
self.wc = self.env['fp.work.centre'].create({
'name': 'WC', 'code': 'WC', 'kind': 'wet_line',
})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 1.0,
})
self.step = self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'S', 'sequence': 10,
'work_centre_id': self.wc.id, 'state': 'ready',
})
def test_start_creates_timelog(self):
self.step.button_start()
self.assertEqual(len(self.step.time_log_ids), 1)
self.assertFalse(self.step.time_log_ids[0].date_finished)
def test_finish_closes_timelog(self):
self.step.button_start()
self.step.button_finish()
log = self.step.time_log_ids[0]
self.assertTrue(log.date_finished)
self.assertGreaterEqual(log.duration_minutes, 0.0)
```
- [ ] **Step 2: Create the model**
Write `fusion_plating/models/fp_job_step_timelog.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.step.timelog — granular start/stop intervals for a step.
#
# Each step.button_start() opens a fresh timelog row. Each
# step.button_finish() (or button_pause once added) closes the open
# row. duration_actual on fp.job.step is the sum of these intervals.
from odoo import api, fields, models
class FpJobStepTimeLog(models.Model):
_name = 'fp.job.step.timelog'
_description = 'Plating Job Step Time Log'
_order = 'date_started desc'
step_id = fields.Many2one(
'fp.job.step',
required=True,
ondelete='cascade',
index=True,
)
user_id = fields.Many2one('res.users', required=True)
date_started = fields.Datetime(required=True)
date_finished = fields.Datetime()
duration_minutes = fields.Float(
compute='_compute_duration', store=True,
)
@api.depends('date_started', 'date_finished')
def _compute_duration(self):
for log in self:
if log.date_started and log.date_finished:
delta = log.date_finished - log.date_started
log.duration_minutes = delta.total_seconds() / 60.0
else:
log.duration_minutes = 0.0
```
- [ ] **Step 3: Register the model**
Modify `fusion_plating/models/__init__.py` — append:
```python
from . import fp_job_step_timelog
```
- [ ] **Step 4: Wire button_start/finish to create/close timelogs**
Modify `fusion_plating/models/fp_job_step.py`:
Add field:
```python
time_log_ids = fields.One2many(
'fp.job.step.timelog',
'step_id',
string='Time Logs',
)
```
Replace `button_start` and `button_finish` to manage timelogs and `duration_actual`:
```python
def button_start(self):
for step in self:
if step.state not in ('ready', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' — only ready/paused steps can start."
) % (step.name, step.state))
step.state = 'in_progress'
if not step.date_started:
step.date_started = fields.Datetime.now()
step.started_by_user_id = self.env.user
self.env['fp.job.step.timelog'].create({
'step_id': step.id,
'user_id': self.env.user.id,
'date_started': fields.Datetime.now(),
})
return True
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
# Close the open timelog
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
now = fields.Datetime.now()
open_log.write({'date_finished': now})
step.state = 'done'
step.date_finished = now
step.finished_by_user_id = self.env.user
# Sum duration_actual
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
return True
```
- [ ] **Step 5: Add ACL rows**
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
- [ ] **Step 6: Update module on dev**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
- [ ] **Step 7: Run tests to verify they pass**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All tests pass including the 2 new timelog tests.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating/models/fp_job_step_timelog.py \
fusion_plating/models/fp_job_step.py \
fusion_plating/models/__init__.py \
fusion_plating/security/ir.model.access.csv \
fusion_plating/tests/test_fp_job_step_state_machine.py
git commit -m "feat(jobs): add fp.job.step.timelog for granular timer tracking
Each button_start opens a timelog; button_finish closes it. Step
duration_actual sums all log intervals. Replicates Odoo MRP's
mrp.workorder.time_ids granularity in our native model.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.8: Basic admin views (form, list, search)
Manager-only views so we can create/inspect job + step records during dev. Operator UI rebuilt in Phase 6.
- [ ] **Step 1: Create work centre views**
Write `fusion_plating/views/fp_work_centre_views.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_work_centre_list" model="ir.ui.view">
<field name="name">fp.work.centre.list</field>
<field name="model">fp.work.centre</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="kind"/>
<field name="facility_id"/>
<field name="cost_per_hour"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_work_centre_form" model="ir.ui.view">
<field name="name">fp.work.centre.form</field>
<field name="model">fp.work.centre</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="code"/>
<field name="name"/>
<field name="kind"/>
<field name="facility_id"/>
<field name="active"/>
</group>
<group>
<field name="cost_per_hour"/>
<field name="currency_id" invisible="1"/>
<field name="default_bath_id"/>
<field name="default_tank_id"/>
<field name="default_oven_id"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_work_centre" model="ir.actions.act_window">
<field name="name">Work Centres</field>
<field name="res_model">fp.work.centre</field>
<field name="view_mode">list,form</field>
</record>
</odoo>
```
- [ ] **Step 2: Create job views**
Write `fusion_plating/views/fp_job_views.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_list" model="ir.ui.view">
<field name="name">fp.job.list</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<list decoration-info="state=='confirmed'"
decoration-success="state=='done'"
decoration-muted="state=='cancelled'">
<field name="name"/>
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="qty"/>
<field name="date_deadline"/>
<field name="state"/>
<field name="step_progress_pct" widget="progressbar"/>
<field name="current_location"/>
</list>
</field>
</record>
<record id="view_fp_job_form" model="ir.ui.view">
<field name="name">fp.job.form</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_confirm" type="object"
string="Confirm" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_cancel" type="object"
string="Cancel"
invisible="state in ('done', 'cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="product_id"/>
<field name="qty"/>
<field name="priority"/>
</group>
<group>
<field name="date_deadline"/>
<field name="date_planned_start"/>
<field name="date_started" readonly="1"/>
<field name="date_finished" readonly="1"/>
<field name="facility_id"/>
<field name="manager_id"/>
</group>
</group>
<notebook>
<page string="Steps" name="steps">
<field name="step_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="work_centre_id"/>
<field name="kind"/>
<field name="state"/>
<field name="assigned_user_id"/>
<field name="duration_expected"/>
<field name="duration_actual" readonly="1"/>
</list>
</field>
</page>
<page string="Source" name="source">
<group>
<field name="origin"/>
<field name="sale_order_id"/>
<field name="recipe_id"/>
<field name="coating_config_id"/>
<field name="customer_spec_id"/>
<field name="start_at_node_id"/>
</group>
</page>
<page string="Costs" name="costs">
<group>
<field name="quoted_revenue"/>
<field name="actual_cost"/>
<field name="margin"/>
<field name="margin_pct"/>
<field name="currency_id" invisible="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_job_search" model="ir.ui.view">
<field name="name">fp.job.search</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="part_catalog_id"/>
<separator/>
<filter name="state_draft" string="Draft" domain="[('state','=','draft')]"/>
<filter name="state_confirmed" string="Confirmed" domain="[('state','=','confirmed')]"/>
<filter name="state_in_progress" string="In Progress" domain="[('state','=','in_progress')]"/>
<filter name="state_done" string="Done" domain="[('state','=','done')]"/>
<separator/>
<filter name="rush" string="Rush" domain="[('priority','=','rush')]"/>
<group>
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_partner" string="Customer" context="{'group_by': 'partner_id'}"/>
<filter name="group_facility" string="Facility" context="{'group_by': 'facility_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_job" model="ir.actions.act_window">
<field name="name">Plating Jobs</field>
<field name="res_model">fp.job</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_search"/>
</record>
</odoo>
```
- [ ] **Step 3: Create job step views**
Write `fusion_plating/views/fp_job_step_views.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_step_form" model="ir.ui.view">
<field name="name">fp.job.step.form</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<form>
<header>
<button name="button_start" type="object"
string="Start" class="btn-primary"
invisible="state not in ('ready', 'paused')"/>
<button name="button_finish" type="object"
string="Finish" class="btn-success"
invisible="state != 'in_progress'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="job_id"/>
<field name="sequence"/>
<field name="work_centre_id"/>
<field name="kind"/>
<field name="recipe_node_id"/>
<field name="assigned_user_id"/>
</group>
<group>
<field name="duration_expected"/>
<field name="duration_actual" readonly="1"/>
<field name="cost_per_hour"/>
<field name="cost_total"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<notebook>
<page string="Equipment" name="equipment">
<group>
<field name="bath_id"/>
<field name="tank_id"/>
<field name="rack_id"/>
<field name="oven_id"/>
<field name="masking_material_id"/>
</group>
</page>
<page string="Plating Spec" name="spec">
<group>
<field name="thickness_target"/>
<field name="thickness_uom"/>
<field name="dwell_time_minutes"/>
<field name="bake_setpoint_temp"/>
<field name="bake_actual_duration"/>
<field name="bake_chart_recorder_ref"/>
</group>
</page>
<page string="Audit" name="audit">
<group>
<field name="started_by_user_id" readonly="1"/>
<field name="date_started" readonly="1"/>
<field name="finished_by_user_id" readonly="1"/>
<field name="date_finished" readonly="1"/>
<field name="signoff_user_id" readonly="1"/>
</group>
<field name="time_log_ids">
<list>
<field name="user_id"/>
<field name="date_started"/>
<field name="date_finished"/>
<field name="duration_minutes"/>
</list>
</field>
</page>
<page string="Instructions" name="instructions">
<field name="instructions" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_job_step" model="ir.actions.act_window">
<field name="name">Job Steps</field>
<field name="res_model">fp.job.step</field>
<field name="view_mode">list,form</field>
</record>
</odoo>
```
- [ ] **Step 4: Create the Plating Jobs root menu (admin-only this phase)**
Write `fusion_plating/views/fp_jobs_menu.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Top-level "Plating Jobs" menu, admin-only during Phase 1.
The fully-fledged operator-facing menu structure lands in
Phase 6 when shopfloor is rewritten. -->
<menuitem id="menu_fp_jobs_root"
name="Plating Jobs (new)"
sequence="47"
groups="fusion_plating.group_fusion_plating_manager"/>
<menuitem id="menu_fp_jobs_jobs"
name="Jobs"
parent="menu_fp_jobs_root"
action="action_fp_job"
sequence="10"/>
<menuitem id="menu_fp_jobs_steps"
name="Steps (Admin)"
parent="menu_fp_jobs_root"
action="action_fp_job_step"
sequence="20"/>
<menuitem id="menu_fp_jobs_work_centres"
name="Work Centres"
parent="menu_fp_jobs_root"
action="action_fp_work_centre"
sequence="30"/>
</odoo>
```
- [ ] **Step 5: Register view files in manifest**
Modify `fusion_plating/__manifest__.py` `data` list — append:
```python
'views/fp_work_centre_views.xml',
'views/fp_job_views.xml',
'views/fp_job_step_views.xml',
'views/fp_jobs_menu.xml',
```
- [ ] **Step 6: Update module + verify menu loads**
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10`
Expected: "Modules loaded" without errors.
Open `http://localhost:8069`, log in as admin. Verify the **Plating Jobs (new)** menu appears with three children: Jobs, Steps (Admin), Work Centres.
- [ ] **Step 7: Manual smoke test**
In the UI, create one Work Centre (kind=wet_line). Create one Job. Add 2 steps. Confirm the job. Click Start on the first step. Verify state changes and timelog rows appear in the Audit tab. Click Finish.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating/views/fp_work_centre_views.xml \
fusion_plating/views/fp_job_views.xml \
fusion_plating/views/fp_job_step_views.xml \
fusion_plating/views/fp_jobs_menu.xml \
fusion_plating/__manifest__.py
git commit -m "feat(jobs): add admin views and menu for fp.job, fp.job.step, fp.work.centre
Manager-only views during Phase 1 — operator UI rebuilt in Phase 6.
Job form has Steps/Source/Costs notebook tabs. Step form has
Equipment/Plating Spec/Audit/Instructions tabs. Search filters by
state, partner, facility.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 1.9: Tag Phase 1 complete + push branch
- [ ] **Step 1: Run full test suite one more time**
Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"`
Expected: All tests pass.
- [ ] **Step 2: Tag the milestone**
```bash
git tag phase-1-complete
git push origin feat/fp-native-job-model
git push origin phase-1-complete
```
- [ ] **Step 3: Sync to entech-dev (NOT entech production)**
The trial container can be used for live exploration. Skip syncing to entech production until after Phase 7 (migration script tested).
```bash
# (optional) deploy to local trial container for hands-on testing
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init
```
- [ ] **Step 4: Phase 1 demo checklist for the user**
Hand off this checklist for user verification before starting Phase 2:
- [ ] Plating Jobs (new) menu visible to manager users
- [ ] Can create a Work Centre with all 6 kinds
- [ ] Can create a Job — sequence assigns `WH/JOB/00001` etc.
- [ ] Can confirm a Job; state moves to confirmed
- [ ] Can add Steps to a confirmed Job
- [ ] Step.button_start creates a timelog and moves state to in_progress
- [ ] Step.button_finish closes the timelog and computes duration_actual
- [ ] Job's step_progress_pct updates as steps complete
- [ ] All tests pass
If any item fails, stop. Don't start Phase 2 with a broken foundation.
---
## Phase 2 (detailed 2026-04-25 after Phase 1 landed)
**Goal:** Build `fusion_plating_jobs` alongside `fusion_plating_bridge_mrp`. The
new module routes SO confirm → `fp.job`, runs the recipe → `fp.job.step` generator,
auto-creates portal jobs / deliveries / certs against the native models, and adds
the 6 cross-module fields deferred from Phase 1.
**Strategy revision (vs. original plan):** original said "rename bridge_mrp → jobs."
Renaming is destructive on entech (a live system). Instead, **build the new module
in parallel**:
- `fusion_plating_bridge_mrp` STAYS installed and primary. Operators keep using
the existing MO-based flow. No regression risk.
- `fusion_plating_jobs` is NEW. It creates `fp.job` records on SO confirm only
when a settings flag (`x_fc_use_native_jobs`) is True. Default: False.
- Both modules can be installed simultaneously without conflict.
- Phase 9 cutover flips the flag for entech, deprecating bridge_mrp's MO creation.
- Phase 10 burn-in keeps bridge_mrp installed read-only as a safety net.
- Eventual deprecation of bridge_mrp = future task, not blocked by this work.
Branch strategy: same `feat/fp-native-job-model` branch.
### Task breakdown
| # | Task | Detail | Effort |
|---|---|---|---|
| 2.1 | Create `fusion_plating_jobs` skeleton | New module dir, manifest with all needed depends (fusion_plating + configurator + portal + logistics + quality + certificates), empty `models/__init__.py`, security ACL stub. Verify clean install on entech. | 0.5d |
| 2.2 | Add cross-module fields to `fp.job` via `_inherit` | The 6 deferred fields (part_catalog_id, coating_config_id, customer_spec_id, portal_job_id, delivery_id, qc_check_id) added in jobs module. Tests. | 0.5d |
| 2.3 | Port `fusion.plating.job.node.override` to jobs module | Move from bridge_mrp; rebind from `mrp.production` to `fp.job`. Keep the bridge_mrp version of this model alive on `mrp.production` for now (parallel). Tests. | 0.5d |
| 2.4 | Recipe → steps generator on `fp.job` | Port `_generate_workorders_from_recipe` from bridge_mrp into a new `fp.job._generate_steps_from_recipe` method. Walks recipe, creates `fp.job.step`. Tests. | 1d |
| 2.5 | Add settings flag `x_fc_use_native_jobs` + SO confirm hook | New flag on res.config.settings (default False). When True, `sale.order.action_confirm` creates `fp.job` instead of `mrp.production`. Tests cover both flag values. | 0.5d |
| 2.6 | Portal job binding from `fp.job` | `fusion.plating.portal.job` gains `x_fc_job_id` Many2one. Auto-create portal job on `fp.job.action_confirm`. Tests. | 0.25d |
| 2.7 | Quality check auto-create | When customer has `x_fc_requires_qc=True`, fp.job.action_confirm spawns a `fusion.plating.quality.check` linked to the job. Tests. | 0.25d |
| 2.8 | Delivery + cert auto-create on done | `fp.job.button_mark_done` creates `fusion.plating.delivery` (draft) and triggers cert generator (CoC + thickness report) like bridge_mrp does for MO done. Tests. | 0.5d |
| 2.9 | Account.move (invoice) hook | When invoice posts, find the linked `fp.job` (via SO origin), update portal_job state to 'complete' and stamp invoice_ref. Mirrors bridge_mrp. Tests. | 0.25d |
| 2.10 | Drop `sale_mrp` from jobs module's depends | Verify zero remaining `sale_mrp`-dependent code paths in jobs. Note: bridge_mrp keeps its sale_mrp dep until cutover. | 0.25d |
| 2.11 | Tag `phase-2-complete` + demo checklist | Full test run, push, tag, demo path on entech with the flag flipped on a test SO. | 0.25d |
**Total: ~5 days engineering, plus review cycles.**
### Demo target after Phase 2
A manager on entech can:
1. Open a fresh sale.order, add a plating line.
2. Toggle `x_fc_use_native_jobs=True` in settings (or per-SO override).
3. Confirm the SO → instead of MO appearing, a `WH/JOB/00001` lands in the new menu.
4. Recipe steps auto-generate as `fp.job.step` rows.
5. Operator (still in old UI for now) doesn't see the fp.job — but a manager can drive it through the admin views.
6. Toggle off the flag → next SO confirm goes back to MO. Bridge_mrp untouched.
---
## Phase 3 (outline only)
**Goal:** Light-touch refactor of 7 dependent modules.
- `fusion_plating_batch``workorder_id``step_id`
- `fusion_plating_quality``production_id`/`workorder_id``job_id`/`step_id` on holds, NCRs, CAPAs
- `fusion_plating_certificates` — cert/thickness backlinks to job
- `fusion_plating_invoicing` — invoice → portal job linkage
- `fusion_plating_logistics` — delivery → job binding
- `fusion_plating_portal` — portal job → job link
- `fusion_plating_receiving` — racking inspection → job link
Estimated: 4 days.
---
## Phase 4 (outline only)
- Configurator (`fusion_plating_configurator`)
- Notifications (`fusion_plating_notifications`) — trigger event renames
- KPIs (`fusion_plating_kpi`) — query domain updates
- Aerospace / Nuclear / CGP / Safety — verify no lingering MRP refs
Estimated: 3 days.
---
## Phase 5 (outline only)
**Reports rewrite:**
- WO Box Sticker — rebind `_mo` → job, change scan URL
- Job Traveller — loop over `fp.job.step_ids`
- WO Margin Report — rollup over `fp.job.step.cost_total`
- BoL, Packing Slip, Invoice — minor cross-ref updates
Estimated: 3 days.
---
## Phase 6 (outline only)
**Shopfloor rewrite — biggest single chunk:**
- Plant Overview — kanban over `fp.job.step` grouped by `fp.work.centre`
- Tablet Station — scan job sticker → job page with embedded process tree
- Process Tree — promoted to primary view (not drill-down)
- Manager Dashboard — list of jobs with progress
- All RPC routes renamed and rebound
Estimated: 6 days.
---
## Phase 7 (outline only)
**Migration script:**
- `fusion_plating_jobs/migrations/19.0.8.0.0/post-migration.py`
- For each `mrp.production` row → `fp.job` row (preserve `WH/MO/...` name; new records get `WH/JOB/...`)
- For each `mrp.workorder` row → `fp.job.step` row
- Migrate `mrp.workorder.time_ids``fp.job.step.timelog`
- Rebind every cross-reference (cert, batch, delivery, portal job, hold)
- Preserve `mail.message` chatter (rebind `res_id` + `model`)
- Preserve `ir.attachment` PDFs (rebind `res_id` + `model`)
- Pre-migration audit script (count snapshot)
- Post-migration audit script (re-validate)
Estimated: 3 days.
---
## Phase 8 (outline only)
**E2E test on entech-clone:**
- Restore entech production DB to staging container
- Run migration
- Replay last 30 days of operator actions
- Run every report
- Render 100 sample CoCs and byte-diff against pre-migration
- Performance baseline (Plant Overview, Job form, report rendering)
Estimated: 5 days.
---
## Phase 9 (outline only)
**Cutover weekend:**
- Friday 6pm: Stop operators
- Friday 8pm: Backup full DB, tag `pre_fp_job_migration`
- Friday 9pm: Deploy + run migration
- Friday 10pm: Smoke test
- Sat/Sun: Buffer for fixes
- Monday 7am: Operators back on with manager + tech on site
Estimated: 1 calendar day (8 hours actual work).
---
## Phase 10 (outline only)
**Burn-in (2 weeks calendar):**
- Daily monitoring of error logs
- Forward-fix issues
- After 14 days, drop `mrp.production` / `mrp.workorder` snapshots
Estimated: 2 weeks calendar (1 day actual work for the snapshot drop).
---
## Self-review checklist (run before user demo)
Before each phase milestone, verify:
- [ ] No commented-out `mrp.production` or `mrp.workorder` references in modified files
- [ ] No `# TODO` or `# FIXME` left in code from this phase
- [ ] All ACL rows added for new models
- [ ] All sequences and data files registered in `__manifest__.py`
- [ ] Module installs cleanly on a fresh DB (`-i fusion_plating --stop-after-init`)
- [ ] Module updates cleanly on existing DB (`-u fusion_plating --stop-after-init`)
- [ ] All tests pass
- [ ] No errors in odoo log on startup or during navigation
---
## Open follow-ups (non-blocking)
These do NOT block Phase 1; they're parking-lot items for later phases:
- **Reverse migration script:** if cutover fails after day 7 we forward-fix. But a "rollback to MRP" reverse script for days 07 should be written before Phase 9.
- **Operator beta:** brief 2 operators in week 7 (during entech-clone test phase) so they're ready Monday.
- **Documentation update:** `fusion_plating/CLAUDE.md` Module Structure section needs the renamed `fusion_plating_bridge_mrp``fusion_plating_jobs`.
- **`docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`** is the single source of truth for design decisions. Any deviation during implementation must be reflected in the spec, not just the code.