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

68 KiB
Raw Blame History

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_mrpfusion_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.productionfp.job; mrp.workorderfp.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
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 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:

    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_ids one2many on fp.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>"

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/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_batchworkorder_idstep_id
  • fusion_plating_qualityproduction_id/workorder_idjob_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_idsfp.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_mrpfusion_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.