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