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>
68 KiB
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, 9–11 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
mainonly 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 #00033stays. Internal model name isfp.jobbut the UI never shows that string. - Migrated records keep their
WH/MO/00033name format. New records getWH/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
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:
# -*- 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:
# -*- 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:
from . import fp_work_centre
- Step 5: Add ACL rows
Modify fusion_plating/security/ir.model.access.csv — append:
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
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:
# -*- 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:
# -*- 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:
from . import fp_job
- Step 5: Add the sequence
Create fusion_plating/data/fp_job_sequences.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:
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
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:
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 byfusion_plating_configurator -
customer_spec_id→ owned byfusion_plating_quality -
portal_job_id→ owned byfusion_plating_portal -
delivery_id→ owned byfusion_plating_logistics -
qc_check_id→ owned byfusion_plating_jobs(Phase 2; the underlying modelfusion.plating.quality.checkcurrently lives infusion_plating_bridge_mrp) -
Step 2: Add cost rollup fields (computed)
Append:
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:
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
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:
# -*- 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:
# -*- 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:
from . import fp_job_step
- Step 5: Add
step_idsone2many onfp.job
Modify fusion_plating/models/fp_job.py — add field after qc_check_id:
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:
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
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:
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:
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:
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:
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
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:
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:
# -*- 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:
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:
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:
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:
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
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 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 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 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 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:
'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
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
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).
# (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/00001etc. - 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_mrpSTAYS installed and primary. Operators keep using the existing MO-based flow. No regression risk.fusion_plating_jobsis NEW. It createsfp.jobrecords 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:
- Open a fresh sale.order, add a plating line.
- Toggle
x_fc_use_native_jobs=Truein settings (or per-SO override). - Confirm the SO → instead of MO appearing, a
WH/JOB/00001lands in the new menu. - Recipe steps auto-generate as
fp.job.steprows. - Operator (still in old UI for now) doesn't see the fp.job — but a manager can drive it through the admin views.
- 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_idfusion_plating_quality—production_id/workorder_id→job_id/step_idon holds, NCRs, CAPAsfusion_plating_certificates— cert/thickness backlinks to jobfusion_plating_invoicing— invoice → portal job linkagefusion_plating_logistics— delivery → job bindingfusion_plating_portal— portal job → job linkfusion_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.stepgrouped byfp.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.productionrow →fp.jobrow (preserveWH/MO/...name; new records getWH/JOB/...) - For each
mrp.workorderrow →fp.job.steprow - Migrate
mrp.workorder.time_ids→fp.job.step.timelog - Rebind every cross-reference (cert, batch, delivery, portal job, hold)
- Preserve
mail.messagechatter (rebindres_id+model) - Preserve
ir.attachmentPDFs (rebindres_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.workordersnapshots
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.productionormrp.workorderreferences in modified files - No
# TODOor# FIXMEleft 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 0–7 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.mdModule Structure section needs the renamedfusion_plating_bridge_mrp→fusion_plating_jobs. docs/superpowers/specs/2026-04-25-fp-native-job-model-design.mdis the single source of truth for design decisions. Any deviation during implementation must be reflected in the spec, not just the code.