# 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 `main` only at cutover. No piecemeal merges (avoids dual-system mess in production). - Daily commits to feature branch; phase milestones tagged: `phase-1-complete`, `phase-2-complete`, etc. - Cursor (the user's other tool) must avoid this branch during the sprint. **Cross-phase invariants:** - Recipe template (`fusion.plating.process.node`) is **never** modified by any task in this plan. If a task changes that model, stop and re-read the spec. - Customer-facing label `WO #00033` stays. Internal model name is `fp.job` but the UI never shows that string. - Migrated records keep their `WH/MO/00033` name format. New records get `WH/JOB/00033`. Both work as scan targets. --- ## Phase 1: Core Models **Goal:** Land the foundational models in `fusion_plating` (the core module). After this phase, the new models exist, can be created/written/queried via shell or admin views, and have basic ACL coverage. No business logic yet — that's Phase 2. **Branch:** `feat/fp-native-job-model` **Files this phase touches:** - Create: `fusion_plating/models/fp_work_centre.py` - Create: `fusion_plating/models/fp_job.py` - Create: `fusion_plating/models/fp_job_step.py` - Create: `fusion_plating/models/fp_job_step_timelog.py` - Create: `fusion_plating/data/fp_job_sequences.xml` - Create: `fusion_plating/views/fp_work_centre_views.xml` - Create: `fusion_plating/views/fp_job_views.xml` - Create: `fusion_plating/views/fp_job_step_views.xml` - Create: `fusion_plating/views/fp_jobs_menu.xml` - Modify: `fusion_plating/models/__init__.py` — add new model imports - Modify: `fusion_plating/__manifest__.py` — bump version, register new data files - Modify: `fusion_plating/security/ir.model.access.csv` — add ACLs - Create: `fusion_plating/tests/test_fp_job_state_machine.py` - Create: `fusion_plating/tests/test_fp_job_step_state_machine.py` --- ### Task 1.1: Create branch + install bare module on local dev - [ ] **Step 1: Create feature branch** ```bash cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating git checkout -b feat/fp-native-job-model ``` - [ ] **Step 2: Verify clean state** Run: `git status` Expected: `On branch feat/fp-native-job-model` and "nothing to commit, working tree clean" - [ ] **Step 3: Verify local dev container is healthy** Run: `docker exec odoo-dev-app odoo --version` Expected: prints Odoo 19 version string - [ ] **Step 4: Commit branch baseline (no changes yet — sanity)** No commit needed. Branch exists, ready for work. --- ### Task 1.2: Create `fp.work.centre` model This replaces `mrp.workcenter` for plating. Domain-specific kinds (wet line / bake / mask / rack / inspect / other). **File:** `fusion_plating/models/fp_work_centre.py` - [ ] **Step 1: Write the failing test** Create `fusion_plating/tests/test_fp_work_centre.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase class TestFpWorkCentre(TransactionCase): def test_create_work_centre_minimal(self): wc = self.env['fp.work.centre'].create({ 'name': 'Bath Line 1', 'code': 'BL1', 'kind': 'wet_line', }) self.assertEqual(wc.name, 'Bath Line 1') self.assertEqual(wc.kind, 'wet_line') self.assertTrue(wc.active) def test_facility_optional_at_create(self): # Facility is soft-required (warning at confirm, not constraint # at create) — verify a centre without facility still creates. wc = self.env['fp.work.centre'].create({ 'name': 'Test', 'code': 'T', 'kind': 'other', }) self.assertFalse(wc.facility_id) def test_kind_selection_values(self): kinds = dict( self.env['fp.work.centre']._fields['kind'].selection ) for k in ('wet_line', 'bake', 'mask', 'rack', 'inspect', 'other'): self.assertIn(k, kinds) ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init -i fusion_plating 2>&1 | tail -20` Expected: ImportError or "Model 'fp.work.centre' does not exist" - [ ] **Step 3: Create the model** Write `fusion_plating/models/fp_work_centre.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # fp.work.centre — native plating work-centre model. # # Replaces mrp.workcenter for the plating flow. Plating work centres # are domain-specific (a tank line, a bake oven, a rack station — not # assembly cells). Each centre has a 'kind' that drives release-ready # validation on fp.job.step (e.g. wet_line → bath+tank required). from odoo import fields, models class FpWorkCentre(models.Model): _name = 'fp.work.centre' _description = 'Plating Work Centre' _order = 'sequence, code, name' name = fields.Char(required=True) code = fields.Char(required=True, help='Short code used on stickers and reports.') sequence = fields.Integer(default=10) facility_id = fields.Many2one( 'fusion.plating.facility', string='Facility', ) kind = fields.Selection( [ ('wet_line', 'Wet Line'), ('bake', 'Bake Oven'), ('mask', 'Masking'), ('rack', 'Racking'), ('inspect', 'Inspection'), ('other', 'Other'), ], required=True, default='other', ) cost_per_hour = fields.Monetary( currency_field='currency_id', help='Used for fp.job.step cost rollups.', ) currency_id = fields.Many2one( 'res.currency', default=lambda self: self.env.company.currency_id, ) default_bath_id = fields.Many2one('fusion.plating.bath') default_tank_id = fields.Many2one('fusion.plating.tank') default_oven_id = fields.Many2one('fusion.plating.oven') active = fields.Boolean(default=True) _sql_constraints = [ ('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'), ] ``` - [ ] **Step 4: Register the model in `__init__.py`** Modify `fusion_plating/models/__init__.py` — add line: ```python from . import fp_work_centre ``` - [ ] **Step 5: Add ACL rows** Modify `fusion_plating/security/ir.model.access.csv` — append: ```csv access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1 ``` - [ ] **Step 6: Update manifest version + module load** Modify `fusion_plating/__manifest__.py` — bump `version` from current to `19.0..0` (check current first), confirm new model gets loaded automatically via `__init__`. - [ ] **Step 7: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 8: Run test to verify it passes** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All 3 tests pass. - [ ] **Step 9: Commit** ```bash git add fusion_plating/models/fp_work_centre.py \ fusion_plating/models/__init__.py \ fusion_plating/security/ir.model.access.csv \ fusion_plating/__manifest__.py \ fusion_plating/tests/test_fp_work_centre.py git commit -m "feat(jobs): add fp.work.centre native model Replaces mrp.workcenter for plating. Domain-specific kinds (wet_line/bake/mask/rack/inspect/other) drive release-ready validation on steps. ACLs follow existing user/supervisor/manager hierarchy. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.3: Create `fp.job` model (header) Replaces `mrp.production`. Header fields only this task; child step relations come in Task 1.5. **File:** `fusion_plating/models/fp_job.py` - [ ] **Step 1: Write the failing tests** Create `fusion_plating/tests/test_fp_job_state_machine.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase from odoo.exceptions import UserError class TestFpJobStateMachine(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({'name': 'Test Customer'}) self.product = self.env['product.product'].create({'name': 'Widget'}) def _make_job(self, **kw): vals = { 'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 10.0, } vals.update(kw) return self.env['fp.job'].create(vals) def test_create_lands_in_draft(self): job = self._make_job() self.assertEqual(job.state, 'draft') self.assertTrue(job.name and job.name.startswith('WH/JOB/')) def test_action_confirm_moves_to_confirmed(self): job = self._make_job() job.action_confirm() self.assertEqual(job.state, 'confirmed') def test_cannot_confirm_twice(self): job = self._make_job() job.action_confirm() with self.assertRaises(UserError): job.action_confirm() def test_cancel_from_draft(self): job = self._make_job() job.action_cancel() self.assertEqual(job.state, 'cancelled') def test_cannot_confirm_after_cancel(self): job = self._make_job() job.action_cancel() with self.assertRaises(UserError): job.action_confirm() ``` - [ ] **Step 2: Run tests to verify they fail** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | tail -20` Expected: 5 tests fail with "Model 'fp.job' does not exist" - [ ] **Step 3: Create the model** Write `fusion_plating/models/fp_job.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # fp.job — native plating job model. # # Replaces mrp.production for plating. One record per shop-floor job. # Header data lives here; per-operation detail on fp.job.step. # Recipe template (fusion.plating.process.node) is unchanged — this # model just instantiates from it via fp.job.step.recipe_node_id. # # State machine: # draft → confirmed → in_progress → done # ↓ ↑ # cancelled (rework reverts here) # on_hold can be entered from confirmed or in_progress. from odoo import _, api, fields, models from odoo.exceptions import UserError class FpJob(models.Model): _name = 'fp.job' _description = 'Plating Job' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'priority desc, date_deadline asc, id desc' _rec_name = 'name' name = fields.Char( required=True, copy=False, readonly=True, default=lambda self: _('New'), index=True, ) state = fields.Selection( [ ('draft', 'Draft'), ('confirmed', 'Confirmed'), ('in_progress', 'In Progress'), ('on_hold', 'On Hold'), ('done', 'Done'), ('cancelled', 'Cancelled'), ], default='draft', required=True, tracking=True, index=True, ) priority = fields.Selection( [ ('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('rush', 'Rush'), ], default='normal', tracking=True, ) partner_id = fields.Many2one( 'res.partner', string='Customer', required=True, tracking=True, ) product_id = fields.Many2one('product.product', string='Reference Product') qty = fields.Float(string='Quantity', required=True, default=1.0) qty_done = fields.Float(string='Quantity Completed') qty_scrapped = fields.Float(string='Quantity Scrapped') date_deadline = fields.Datetime(string='Deadline', tracking=True) date_planned_start = fields.Datetime(string='Planned Start') date_started = fields.Datetime(string='Actual Start', readonly=True) date_finished = fields.Datetime(string='Actual Finish', readonly=True) origin = fields.Char(string='Source SO', help='Sale Order name for traceability.') sale_order_id = fields.Many2one('sale.order', string='Sale Order') facility_id = fields.Many2one('fusion.plating.facility', string='Facility') manager_id = fields.Many2one('res.users', string='Plating Manager') company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, required=True, ) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', _('New')) == _('New'): vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New') return super().create(vals_list) def action_confirm(self): for job in self: if job.state != 'draft': raise UserError(_( "Job %s is in state '%s' — only draft jobs can be confirmed." ) % (job.name, job.state)) job.state = 'confirmed' return True def action_cancel(self): for job in self: if job.state == 'done': raise UserError(_( "Job %s is done — cannot cancel." ) % job.name) job.state = 'cancelled' return True ``` - [ ] **Step 4: Register the model in `__init__.py`** Modify `fusion_plating/models/__init__.py` — append: ```python from . import fp_job ``` - [ ] **Step 5: Add the sequence** Create `fusion_plating/data/fp_job_sequences.xml`: ```xml Plating Job Sequence fp.job WH/JOB/ 5 1 1 ``` - [ ] **Step 6: Register sequence file in manifest** Modify `fusion_plating/__manifest__.py` — add `'data/fp_job_sequences.xml'` to the `data` list (after existing data files, before view files). - [ ] **Step 7: Add ACL rows** Modify `fusion_plating/security/ir.model.access.csv` — append: ```csv access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0 access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1 ``` - [ ] **Step 8: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 9: Run tests to verify they pass** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All 5 tests pass. - [ ] **Step 10: Commit** ```bash git add fusion_plating/models/fp_job.py \ fusion_plating/models/__init__.py \ fusion_plating/data/fp_job_sequences.xml \ fusion_plating/security/ir.model.access.csv \ fusion_plating/__manifest__.py \ fusion_plating/tests/test_fp_job_state_machine.py git commit -m "feat(jobs): add fp.job native model with state machine Header model replacing mrp.production. Mail thread for chatter, priority/state/deadline tracking, sequence WH/JOB/00001+. Tests cover create, confirm, cancel, and forbidden double-confirm. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.4: Add core-safe extension fields to `fp.job` **Scope reduction (2026-04-25):** Originally this task added all spec §5.1 fields. But the dependency audit during Task 1.4 implementation revealed that 6 of those fields point to models in modules that depend on `fusion_plating` core (configurator, quality, portal, logistics, bridge_mrp). Adding them in core would invert the dependency graph. **Per the updated spec §5.1**, those fields are deferred to their owning modules via `_inherit = 'fp.job'` and re-bundled by `fusion_plating_jobs` in Phase 2. This task now lands ONLY the fields whose target models are reachable from core's existing `depends` (sale_management → sale → account, and our own process.node): - [ ] **Step 1: Add SO + recipe core-safe fields** Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`: ```python sale_order_line_ids = fields.Many2many( 'sale.order.line', 'fp_job_sale_order_line_rel', 'job_id', 'line_id', string='Source SO Lines', ) recipe_id = fields.Many2one( 'fusion.plating.process.node', string='Recipe', domain=[('node_type', '=', 'recipe')], ) start_at_node_id = fields.Many2one( 'fusion.plating.process.node', string='Start at Node', help='Rework: start the job at this recipe node (skip earlier).', ) invoice_ids = fields.Many2many( 'account.move', 'fp_job_account_move_rel', 'job_id', 'move_id', string='Invoices', ) ``` **Deferred to bridge modules (DO NOT add in this task):** - `part_catalog_id`, `coating_config_id` → owned by `fusion_plating_configurator` - `customer_spec_id` → owned by `fusion_plating_quality` - `portal_job_id` → owned by `fusion_plating_portal` - `delivery_id` → owned by `fusion_plating_logistics` - `qc_check_id` → owned by `fusion_plating_jobs` (Phase 2; the underlying model `fusion.plating.quality.check` currently lives in `fusion_plating_bridge_mrp`) - [ ] **Step 2: Add cost rollup fields (computed)** Append: ```python quoted_revenue = fields.Monetary( currency_field='currency_id', help='From source SO.', ) actual_cost = fields.Monetary( currency_field='currency_id', compute='_compute_costs', store=True, ) margin = fields.Monetary( currency_field='currency_id', compute='_compute_costs', store=True, ) margin_pct = fields.Float( compute='_compute_costs', store=True, ) currency_id = fields.Many2one( 'res.currency', default=lambda self: self.env.company.currency_id, ) @api.depends('quoted_revenue') # step cost added in 1.5 def _compute_costs(self): for job in self: # Step time × rate rollup added in Task 1.5 once steps exist job.actual_cost = 0.0 job.margin = job.quoted_revenue - job.actual_cost job.margin_pct = ( (job.margin / job.quoted_revenue * 100.0) if job.quoted_revenue else 0.0 ) ``` - [ ] **Step 3: Add current_location computed field** Append: ```python current_location = fields.Char( compute='_compute_current_location', help='Human-readable: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship".', ) def _compute_current_location(self): # Full implementation lands in Task 1.6 once steps + work centres exist. for job in self: if job.state == 'draft': job.current_location = 'Not started' elif job.state == 'cancelled': job.current_location = 'Cancelled' elif job.state == 'done': job.current_location = 'Done' else: job.current_location = job.state.replace('_', ' ').title() ``` - [ ] **Step 4: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 5: Run tests to verify they still pass** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All previous tests still pass; new fields don't break anything. - [ ] **Step 6: Commit** ```bash git add fusion_plating/models/fp_job.py git commit -m "feat(jobs): add SO/recipe/portal/cost fields to fp.job Extension fields covering source SO traceability, recipe link, portal/delivery binding, cost rollup placeholders. Cost compute is a stub until steps are added in Task 1.5. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.5: Create `fp.job.step` model The per-operation model. Replaces `mrp.workorder`. **File:** `fusion_plating/models/fp_job_step.py` - [ ] **Step 1: Write the failing test** Create `fusion_plating/tests/test_fp_job_step_state_machine.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase from odoo.exceptions import UserError class TestFpJobStepStateMachine(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({'name': 'Cust'}) self.product = self.env['product.product'].create({'name': 'Widget'}) self.wc = self.env['fp.work.centre'].create({ 'name': 'WC', 'code': 'WC', 'kind': 'wet_line', }) self.job = self.env['fp.job'].create({ 'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 1.0, }) def _make_step(self, **kw): vals = { 'job_id': self.job.id, 'name': 'Plating Bath', 'sequence': 10, 'work_centre_id': self.wc.id, } vals.update(kw) return self.env['fp.job.step'].create(vals) def test_step_starts_pending(self): step = self._make_step() self.assertEqual(step.state, 'pending') def test_first_step_becomes_ready_on_job_confirm(self): step = self._make_step() self.job.action_confirm() # Predecessor logic: step with no earlier sibling → ready step._compute_state_ready() self.assertIn(step.state, ('ready', 'pending')) # pending acceptable until 1.6 def test_button_start_moves_to_in_progress(self): step = self._make_step() step.state = 'ready' step.button_start() self.assertEqual(step.state, 'in_progress') self.assertTrue(step.date_started) def test_button_finish_requires_in_progress(self): step = self._make_step() with self.assertRaises(UserError): step.button_finish() # state is pending def test_button_finish_moves_to_done(self): step = self._make_step() step.state = 'ready' step.button_start() step.button_finish() self.assertEqual(step.state, 'done') self.assertTrue(step.date_finished) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | tail -20` Expected: All 5 tests fail with "Model 'fp.job.step' does not exist" - [ ] **Step 3: Create the model — minimal fields + state machine** Write `fusion_plating/models/fp_job_step.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # fp.job.step — one operation within a plating job. # # Replaces mrp.workorder. Each step instantiates from a recipe # operation node (recipe_node_id). Container nodes (recipe, # sub_process) and step nodes (instructions) are NOT rows here — # they live on the recipe template and are used at view-render time # to display hierarchy. See spec §5.2 (Option A — operations only). from odoo import _, api, fields, models from odoo.exceptions import UserError class FpJobStep(models.Model): _name = 'fp.job.step' _description = 'Plating Job Step' _inherit = ['mail.thread'] _order = 'job_id, sequence, id' job_id = fields.Many2one( 'fp.job', required=True, ondelete='cascade', index=True, ) name = fields.Char(required=True) sequence = fields.Integer(default=10) state = fields.Selection( [ ('pending', 'Pending'), ('ready', 'Ready'), ('in_progress', 'In Progress'), ('paused', 'Paused'), ('done', 'Done'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled'), ], default='pending', required=True, tracking=True, index=True, ) recipe_node_id = fields.Many2one( 'fusion.plating.process.node', string='Recipe Operation', domain=[('node_type', '=', 'operation')], ) work_centre_id = fields.Many2one('fp.work.centre', index=True) kind = fields.Selection( [ ('wet', 'Wet'), ('bake', 'Bake'), ('mask', 'Mask'), ('rack', 'Rack'), ('inspect', 'Inspect'), ('other', 'Other'), ], default='other', ) assigned_user_id = fields.Many2one('res.users', tracking=True) started_by_user_id = fields.Many2one('res.users', readonly=True) finished_by_user_id = fields.Many2one('res.users', readonly=True) date_started = fields.Datetime(readonly=True) date_finished = fields.Datetime(readonly=True) duration_expected = fields.Float(string='Expected Minutes') duration_actual = fields.Float(string='Actual Minutes', readonly=True) instructions = fields.Html(string='Step Instructions') def button_start(self): for step in self: if step.state not in ('ready', 'paused'): raise UserError(_( "Step '%s' is in state '%s' — only ready/paused steps can start." ) % (step.name, step.state)) step.state = 'in_progress' if not step.date_started: step.date_started = fields.Datetime.now() step.started_by_user_id = self.env.user return True def button_finish(self): for step in self: if step.state != 'in_progress': raise UserError(_( "Step '%s' is in state '%s' — only in-progress steps can finish." ) % (step.name, step.state)) step.state = 'done' step.date_finished = fields.Datetime.now() step.finished_by_user_id = self.env.user return True def _compute_state_ready(self): # Stub: full predecessor logic in Task 1.6 for step in self: if step.state == 'pending' and step.job_id.state in ('confirmed', 'in_progress'): # First-sequence step in its job becomes ready first = step.job_id.step_ids.sorted('sequence')[:1] if first and first.id == step.id: step.state = 'ready' ``` - [ ] **Step 4: Register the model** Modify `fusion_plating/models/__init__.py` — append: ```python from . import fp_job_step ``` - [ ] **Step 5: Add `step_ids` one2many on `fp.job`** Modify `fusion_plating/models/fp_job.py` — add field after `qc_check_id`: ```python step_ids = fields.One2many( 'fp.job.step', 'job_id', string='Steps', ) step_count = fields.Integer(compute='_compute_step_counts') step_done_count = fields.Integer(compute='_compute_step_counts') step_progress_pct = fields.Float(compute='_compute_step_counts') current_step_id = fields.Many2one( 'fp.job.step', compute='_compute_current_step', ) @api.depends('step_ids', 'step_ids.state') def _compute_step_counts(self): for job in self: job.step_count = len(job.step_ids) job.step_done_count = len(job.step_ids.filtered(lambda s: s.state == 'done')) job.step_progress_pct = ( (job.step_done_count / job.step_count * 100.0) if job.step_count else 0.0 ) @api.depends('step_ids.state', 'step_ids.sequence') def _compute_current_step(self): for job in self: in_prog = job.step_ids.filtered(lambda s: s.state == 'in_progress') if in_prog: job.current_step_id = in_prog.sorted('sequence')[:1] continue ready = job.step_ids.filtered(lambda s: s.state == 'ready') if ready: job.current_step_id = ready.sorted('sequence')[:1] continue job.current_step_id = False ``` - [ ] **Step 6: Add ACL rows** Modify `fusion_plating/security/ir.model.access.csv` — append: ```csv access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0 access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1 ``` - [ ] **Step 7: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 8: Run tests to verify they pass** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All previous tests pass + 5 new step tests pass. - [ ] **Step 9: Commit** ```bash git add fusion_plating/models/fp_job_step.py \ fusion_plating/models/fp_job.py \ fusion_plating/models/__init__.py \ fusion_plating/security/ir.model.access.csv \ fusion_plating/tests/test_fp_job_step_state_machine.py git commit -m "feat(jobs): add fp.job.step model with state machine Per-operation model replacing mrp.workorder. Mirrors operations from the recipe template (recipe_node_id link). 7-state machine: pending → ready → in_progress → paused/done. Job header gets step_ids + computed counts + current_step_id. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.6: Add equipment, audit, plating-spec fields to `fp.job.step` The remaining fields from spec §5.2. - [ ] **Step 1: Add equipment + audit fields** Modify `fusion_plating/models/fp_job_step.py` — append fields: ```python bath_id = fields.Many2one('fusion.plating.bath') tank_id = fields.Many2one('fusion.plating.tank') rack_id = fields.Many2one('fusion.plating.rack') oven_id = fields.Many2one('fusion.plating.oven') masking_material_id = fields.Many2one('fusion.plating.masking.material') signoff_user_id = fields.Many2one('res.users', readonly=True) facility_id = fields.Many2one( 'fusion.plating.facility', related='work_centre_id.facility_id', store=True, ) ``` - [ ] **Step 2: Add plating-spec fields** Append: ```python thickness_target = fields.Float(string='Target Thickness') thickness_uom = fields.Selection( [('um', 'µm'), ('mil', 'mil'), ('inch', 'in')], default='um', ) dwell_time_minutes = fields.Float() bake_setpoint_temp = fields.Float(string='Bake Setpoint °C') bake_actual_duration = fields.Float(string='Bake Actual Minutes') bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref') ``` - [ ] **Step 3: Add recipe-related boolean fields** Append: ```python requires_signoff = fields.Boolean( related='recipe_node_id.requires_signoff', store=True, ) auto_complete = fields.Boolean( related='recipe_node_id.auto_complete', store=True, ) is_manual = fields.Boolean( related='recipe_node_id.is_manual', store=True, ) customer_visible = fields.Boolean( related='recipe_node_id.customer_visible', store=True, ) ``` - [ ] **Step 4: Add cost computed field** Append: ```python cost_per_hour = fields.Monetary( related='work_centre_id.cost_per_hour', currency_field='currency_id', ) cost_total = fields.Monetary( compute='_compute_cost_total', store=True, currency_field='currency_id', ) currency_id = fields.Many2one( 'res.currency', related='work_centre_id.currency_id', ) @api.depends('duration_actual', 'cost_per_hour') def _compute_cost_total(self): for step in self: step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour ``` - [ ] **Step 5: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 6: Run all tests** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All tests still pass. - [ ] **Step 7: Commit** ```bash git add fusion_plating/models/fp_job_step.py git commit -m "feat(jobs): add equipment, audit, plating-spec fields to fp.job.step Bath/tank/rack/oven/mask equipment links, sign-off audit user, plating thickness target + UoM, bake parameters (Nadcap audit), recipe-related booleans (requires_signoff, auto_complete, is_manual, customer_visible) as related fields, cost rollup. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.7: Create `fp.job.step.timelog` Granular start/stop tracking. Each pause creates a record. **File:** `fusion_plating/models/fp_job_step_timelog.py` - [ ] **Step 1: Write the failing test** Append to `fusion_plating/tests/test_fp_job_step_state_machine.py`: ```python class TestFpJobStepTimeLog(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({'name': 'Cust'}) self.product = self.env['product.product'].create({'name': 'Widget'}) self.wc = self.env['fp.work.centre'].create({ 'name': 'WC', 'code': 'WC', 'kind': 'wet_line', }) self.job = self.env['fp.job'].create({ 'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 1.0, }) self.step = self.env['fp.job.step'].create({ 'job_id': self.job.id, 'name': 'S', 'sequence': 10, 'work_centre_id': self.wc.id, 'state': 'ready', }) def test_start_creates_timelog(self): self.step.button_start() self.assertEqual(len(self.step.time_log_ids), 1) self.assertFalse(self.step.time_log_ids[0].date_finished) def test_finish_closes_timelog(self): self.step.button_start() self.step.button_finish() log = self.step.time_log_ids[0] self.assertTrue(log.date_finished) self.assertGreaterEqual(log.duration_minutes, 0.0) ``` - [ ] **Step 2: Create the model** Write `fusion_plating/models/fp_job_step_timelog.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # fp.job.step.timelog — granular start/stop intervals for a step. # # Each step.button_start() opens a fresh timelog row. Each # step.button_finish() (or button_pause once added) closes the open # row. duration_actual on fp.job.step is the sum of these intervals. from odoo import api, fields, models class FpJobStepTimeLog(models.Model): _name = 'fp.job.step.timelog' _description = 'Plating Job Step Time Log' _order = 'date_started desc' step_id = fields.Many2one( 'fp.job.step', required=True, ondelete='cascade', index=True, ) user_id = fields.Many2one('res.users', required=True) date_started = fields.Datetime(required=True) date_finished = fields.Datetime() duration_minutes = fields.Float( compute='_compute_duration', store=True, ) @api.depends('date_started', 'date_finished') def _compute_duration(self): for log in self: if log.date_started and log.date_finished: delta = log.date_finished - log.date_started log.duration_minutes = delta.total_seconds() / 60.0 else: log.duration_minutes = 0.0 ``` - [ ] **Step 3: Register the model** Modify `fusion_plating/models/__init__.py` — append: ```python from . import fp_job_step_timelog ``` - [ ] **Step 4: Wire button_start/finish to create/close timelogs** Modify `fusion_plating/models/fp_job_step.py`: Add field: ```python time_log_ids = fields.One2many( 'fp.job.step.timelog', 'step_id', string='Time Logs', ) ``` Replace `button_start` and `button_finish` to manage timelogs and `duration_actual`: ```python def button_start(self): for step in self: if step.state not in ('ready', 'paused'): raise UserError(_( "Step '%s' is in state '%s' — only ready/paused steps can start." ) % (step.name, step.state)) step.state = 'in_progress' if not step.date_started: step.date_started = fields.Datetime.now() step.started_by_user_id = self.env.user self.env['fp.job.step.timelog'].create({ 'step_id': step.id, 'user_id': self.env.user.id, 'date_started': fields.Datetime.now(), }) return True def button_finish(self): for step in self: if step.state != 'in_progress': raise UserError(_( "Step '%s' is in state '%s' — only in-progress steps can finish." ) % (step.name, step.state)) # Close the open timelog open_log = step.time_log_ids.filtered(lambda l: not l.date_finished) now = fields.Datetime.now() open_log.write({'date_finished': now}) step.state = 'done' step.date_finished = now step.finished_by_user_id = self.env.user # Sum duration_actual step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes')) return True ``` - [ ] **Step 5: Add ACL rows** Modify `fusion_plating/security/ir.model.access.csv` — append: ```csv access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0 access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1 ``` - [ ] **Step 6: Update module on dev** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. - [ ] **Step 7: Run tests to verify they pass** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All tests pass including the 2 new timelog tests. - [ ] **Step 8: Commit** ```bash git add fusion_plating/models/fp_job_step_timelog.py \ fusion_plating/models/fp_job_step.py \ fusion_plating/models/__init__.py \ fusion_plating/security/ir.model.access.csv \ fusion_plating/tests/test_fp_job_step_state_machine.py git commit -m "feat(jobs): add fp.job.step.timelog for granular timer tracking Each button_start opens a timelog; button_finish closes it. Step duration_actual sums all log intervals. Replicates Odoo MRP's mrp.workorder.time_ids granularity in our native model. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### 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 fp.work.centre.list fp.work.centre fp.work.centre.form fp.work.centre
Work Centres fp.work.centre list,form
``` - [ ] **Step 2: Create job views** Write `fusion_plating/views/fp_job_views.xml`: ```xml fp.job.list fp.job fp.job.form fp.job

fp.job.search fp.job Plating Jobs fp.job list,form
``` - [ ] **Step 3: Create job step views** Write `fusion_plating/views/fp_job_step_views.xml`: ```xml fp.job.step.form fp.job.step

Job Steps fp.job.step list,form
``` - [ ] **Step 4: Create the Plating Jobs root menu (admin-only this phase)** Write `fusion_plating/views/fp_jobs_menu.xml`: ```xml ``` - [ ] **Step 5: Register view files in manifest** Modify `fusion_plating/__manifest__.py` `data` list — append: ```python 'views/fp_work_centre_views.xml', 'views/fp_job_views.xml', 'views/fp_job_step_views.xml', 'views/fp_jobs_menu.xml', ``` - [ ] **Step 6: Update module + verify menu loads** Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -10` Expected: "Modules loaded" without errors. Open `http://localhost:8069`, log in as admin. Verify the **Plating Jobs (new)** menu appears with three children: Jobs, Steps (Admin), Work Centres. - [ ] **Step 7: Manual smoke test** In the UI, create one Work Centre (kind=wet_line). Create one Job. Add 2 steps. Confirm the job. Click Start on the first step. Verify state changes and timelog rows appear in the Audit tab. Click Finish. - [ ] **Step 8: Commit** ```bash git add fusion_plating/views/fp_work_centre_views.xml \ fusion_plating/views/fp_job_views.xml \ fusion_plating/views/fp_job_step_views.xml \ fusion_plating/views/fp_jobs_menu.xml \ fusion_plating/__manifest__.py git commit -m "feat(jobs): add admin views and menu for fp.job, fp.job.step, fp.work.centre Manager-only views during Phase 1 — operator UI rebuilt in Phase 6. Job form has Steps/Source/Costs notebook tabs. Step form has Equipment/Plating Spec/Audit/Instructions tabs. Search filters by state, partner, facility. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ### Task 1.9: Tag Phase 1 complete + push branch - [ ] **Step 1: Run full test suite one more time** Run: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_plating --stop-after-init 2>&1 | grep -E "FAILED|PASSED|Ran"` Expected: All tests pass. - [ ] **Step 2: Tag the milestone** ```bash git tag phase-1-complete git push origin feat/fp-native-job-model git push origin phase-1-complete ``` - [ ] **Step 3: Sync to entech-dev (NOT entech production)** The trial container can be used for live exploration. Skip syncing to entech production until after Phase 7 (migration script tested). ```bash # (optional) deploy to local trial container for hands-on testing docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init ``` - [ ] **Step 4: Phase 1 demo checklist for the user** Hand off this checklist for user verification before starting Phase 2: - [ ] Plating Jobs (new) menu visible to manager users - [ ] Can create a Work Centre with all 6 kinds - [ ] Can create a Job — sequence assigns `WH/JOB/00001` etc. - [ ] Can confirm a Job; state moves to confirmed - [ ] Can add Steps to a confirmed Job - [ ] Step.button_start creates a timelog and moves state to in_progress - [ ] Step.button_finish closes the timelog and computes duration_actual - [ ] Job's step_progress_pct updates as steps complete - [ ] All tests pass If any item fails, stop. Don't start Phase 2 with a broken foundation. --- ## Phase 2 (detailed 2026-04-25 after Phase 1 landed) **Goal:** Build `fusion_plating_jobs` alongside `fusion_plating_bridge_mrp`. The new module routes SO confirm → `fp.job`, runs the recipe → `fp.job.step` generator, auto-creates portal jobs / deliveries / certs against the native models, and adds the 6 cross-module fields deferred from Phase 1. **Strategy revision (vs. original plan):** original said "rename bridge_mrp → jobs." Renaming is destructive on entech (a live system). Instead, **build the new module in parallel**: - `fusion_plating_bridge_mrp` STAYS installed and primary. Operators keep using the existing MO-based flow. No regression risk. - `fusion_plating_jobs` is NEW. It creates `fp.job` records on SO confirm only when a settings flag (`x_fc_use_native_jobs`) is True. Default: False. - Both modules can be installed simultaneously without conflict. - Phase 9 cutover flips the flag for entech, deprecating bridge_mrp's MO creation. - Phase 10 burn-in keeps bridge_mrp installed read-only as a safety net. - Eventual deprecation of bridge_mrp = future task, not blocked by this work. Branch strategy: same `feat/fp-native-job-model` branch. ### Task breakdown | # | Task | Detail | Effort | |---|---|---|---| | 2.1 | Create `fusion_plating_jobs` skeleton | New module dir, manifest with all needed depends (fusion_plating + configurator + portal + logistics + quality + certificates), empty `models/__init__.py`, security ACL stub. Verify clean install on entech. | 0.5d | | 2.2 | Add cross-module fields to `fp.job` via `_inherit` | The 6 deferred fields (part_catalog_id, coating_config_id, customer_spec_id, portal_job_id, delivery_id, qc_check_id) added in jobs module. Tests. | 0.5d | | 2.3 | Port `fusion.plating.job.node.override` to jobs module | Move from bridge_mrp; rebind from `mrp.production` to `fp.job`. Keep the bridge_mrp version of this model alive on `mrp.production` for now (parallel). Tests. | 0.5d | | 2.4 | Recipe → steps generator on `fp.job` | Port `_generate_workorders_from_recipe` from bridge_mrp into a new `fp.job._generate_steps_from_recipe` method. Walks recipe, creates `fp.job.step`. Tests. | 1d | | 2.5 | Add settings flag `x_fc_use_native_jobs` + SO confirm hook | New flag on res.config.settings (default False). When True, `sale.order.action_confirm` creates `fp.job` instead of `mrp.production`. Tests cover both flag values. | 0.5d | | 2.6 | Portal job binding from `fp.job` | `fusion.plating.portal.job` gains `x_fc_job_id` Many2one. Auto-create portal job on `fp.job.action_confirm`. Tests. | 0.25d | | 2.7 | Quality check auto-create | When customer has `x_fc_requires_qc=True`, fp.job.action_confirm spawns a `fusion.plating.quality.check` linked to the job. Tests. | 0.25d | | 2.8 | Delivery + cert auto-create on done | `fp.job.button_mark_done` creates `fusion.plating.delivery` (draft) and triggers cert generator (CoC + thickness report) like bridge_mrp does for MO done. Tests. | 0.5d | | 2.9 | Account.move (invoice) hook | When invoice posts, find the linked `fp.job` (via SO origin), update portal_job state to 'complete' and stamp invoice_ref. Mirrors bridge_mrp. Tests. | 0.25d | | 2.10 | Drop `sale_mrp` from jobs module's depends | Verify zero remaining `sale_mrp`-dependent code paths in jobs. Note: bridge_mrp keeps its sale_mrp dep until cutover. | 0.25d | | 2.11 | Tag `phase-2-complete` + demo checklist | Full test run, push, tag, demo path on entech with the flag flipped on a test SO. | 0.25d | **Total: ~5 days engineering, plus review cycles.** ### Demo target after Phase 2 A manager on entech can: 1. Open a fresh sale.order, add a plating line. 2. Toggle `x_fc_use_native_jobs=True` in settings (or per-SO override). 3. Confirm the SO → instead of MO appearing, a `WH/JOB/00001` lands in the new menu. 4. Recipe steps auto-generate as `fp.job.step` rows. 5. Operator (still in old UI for now) doesn't see the fp.job — but a manager can drive it through the admin views. 6. Toggle off the flag → next SO confirm goes back to MO. Bridge_mrp untouched. --- ## Phase 3 (outline only) **Goal:** Light-touch refactor of 7 dependent modules. - `fusion_plating_batch` — `workorder_id` → `step_id` - `fusion_plating_quality` — `production_id`/`workorder_id` → `job_id`/`step_id` on holds, NCRs, CAPAs - `fusion_plating_certificates` — cert/thickness backlinks to job - `fusion_plating_invoicing` — invoice → portal job linkage - `fusion_plating_logistics` — delivery → job binding - `fusion_plating_portal` — portal job → job link - `fusion_plating_receiving` — racking inspection → job link Estimated: 4 days. --- ## Phase 4 (outline only) - Configurator (`fusion_plating_configurator`) - Notifications (`fusion_plating_notifications`) — trigger event renames - KPIs (`fusion_plating_kpi`) — query domain updates - Aerospace / Nuclear / CGP / Safety — verify no lingering MRP refs Estimated: 3 days. --- ## Phase 5 (outline only) **Reports rewrite:** - WO Box Sticker — rebind `_mo` → job, change scan URL - Job Traveller — loop over `fp.job.step_ids` - WO Margin Report — rollup over `fp.job.step.cost_total` - BoL, Packing Slip, Invoice — minor cross-ref updates Estimated: 3 days. --- ## Phase 6 (outline only) **Shopfloor rewrite — biggest single chunk:** - Plant Overview — kanban over `fp.job.step` grouped by `fp.work.centre` - Tablet Station — scan job sticker → job page with embedded process tree - Process Tree — promoted to primary view (not drill-down) - Manager Dashboard — list of jobs with progress - All RPC routes renamed and rebound Estimated: 6 days. --- ## Phase 7 (outline only) **Migration script:** - `fusion_plating_jobs/migrations/19.0.8.0.0/post-migration.py` - For each `mrp.production` row → `fp.job` row (preserve `WH/MO/...` name; new records get `WH/JOB/...`) - For each `mrp.workorder` row → `fp.job.step` row - Migrate `mrp.workorder.time_ids` → `fp.job.step.timelog` - Rebind every cross-reference (cert, batch, delivery, portal job, hold) - Preserve `mail.message` chatter (rebind `res_id` + `model`) - Preserve `ir.attachment` PDFs (rebind `res_id` + `model`) - Pre-migration audit script (count snapshot) - Post-migration audit script (re-validate) Estimated: 3 days. --- ## Phase 8 (outline only) **E2E test on entech-clone:** - Restore entech production DB to staging container - Run migration - Replay last 30 days of operator actions - Run every report - Render 100 sample CoCs and byte-diff against pre-migration - Performance baseline (Plant Overview, Job form, report rendering) Estimated: 5 days. --- ## Phase 9 (outline only) **Cutover weekend:** - Friday 6pm: Stop operators - Friday 8pm: Backup full DB, tag `pre_fp_job_migration` - Friday 9pm: Deploy + run migration - Friday 10pm: Smoke test - Sat/Sun: Buffer for fixes - Monday 7am: Operators back on with manager + tech on site Estimated: 1 calendar day (8 hours actual work). --- ## Phase 10 (outline only) **Burn-in (2 weeks calendar):** - Daily monitoring of error logs - Forward-fix issues - After 14 days, drop `mrp.production` / `mrp.workorder` snapshots Estimated: 2 weeks calendar (1 day actual work for the snapshot drop). --- ## Self-review checklist (run before user demo) Before each phase milestone, verify: - [ ] No commented-out `mrp.production` or `mrp.workorder` references in modified files - [ ] No `# TODO` or `# FIXME` left in code from this phase - [ ] All ACL rows added for new models - [ ] All sequences and data files registered in `__manifest__.py` - [ ] Module installs cleanly on a fresh DB (`-i fusion_plating --stop-after-init`) - [ ] Module updates cleanly on existing DB (`-u fusion_plating --stop-after-init`) - [ ] All tests pass - [ ] No errors in odoo log on startup or during navigation --- ## Open follow-ups (non-blocking) These do NOT block Phase 1; they're parking-lot items for later phases: - **Reverse migration script:** if cutover fails after day 7 we forward-fix. But a "rollback to MRP" reverse script for days 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.md` Module Structure section needs the renamed `fusion_plating_bridge_mrp` → `fusion_plating_jobs`. - **`docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`** is the single source of truth for design decisions. Any deviation during implementation must be reflected in the spec, not just the code.