# Post-Shop Cert + Shipping Job States 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:** Stop completed-but-uncertified jobs from disappearing off the Plant Kanban; route them through two new `fp.job.state` values (`awaiting_cert` → `awaiting_ship`) with auto-transitions, a Quality Dashboard "Certificates" tab, hard-gated CoC issuance (QM/Manager/Owner only), and email + in-app activity notifications.
**Architecture:** Add two intermediate `fp.job.state` values via Selection extension in `fusion_plating_jobs`. Auto-advance on last-step finish (gates moved into `fp.job.step.button_finish` so failures surface as UserError on the operator's click, not silently swallowed). Cert `action_issue` advances to `awaiting_ship`; cert void regresses to `awaiting_cert`. Plant kanban widens its `state` domain; new `_resolve_card_area` rules pin the two states to the right-most two columns. Six-tab Quality Dashboard adds a Certificates kanban. ACL is enforced both as a Python `AccessError` and a view-level `groups=` clause. Notifications go through the existing `fp.notification.template` framework with a new group-membership recipient resolver; `mail.activity` is the belt-and-suspenders fallback.
**Spec:** [docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md](../specs/2026-05-25-post-shop-cert-shipping-job-states-design.md)
**Tech Stack:** Odoo 19, Python (api/orm/migrations), PostgreSQL, QWeb XML, OWL components, SCSS.
---
## File Inventory (what each task touches)
| Path | Responsibility |
|---|---|
| `fusion_plating_jobs/models/fp_job.py` | Selection extension for state; auto-advance helpers; `button_mark_shipped`; activity helpers; mini-timeline update; card-state extension |
| `fusion_plating_jobs/models/fp_job_step.py` | Hook `button_finish` to fire post-shop gates on the last open step |
| `fusion_plating_jobs/views/fp_job_views.xml` | `Mark Shipped` button (replaces `Mark Done` for end-of-line); groups gating |
| `fusion_plating_jobs/data/fp_activity_types_data.xml` | NEW — `mail.activity.type` `activity_type_issue_coc` |
| `fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py` | NEW — backfill mid-flight jobs into new states |
| `fusion_plating_jobs/tests/test_post_shop_states.py` | NEW — unit coverage for the new transitions + helpers |
| `fusion_plating_jobs/__manifest__.py` | Version bump `19.0.10.31.0` → `19.0.11.0.0` |
| `fusion_plating_certificates/models/fp_certificate.py` | `action_issue` ACL guard + post-issue hook; `write({'state':'voided'})` override; `x_fc_age_hours` computed |
| `fusion_plating_certificates/views/fp_certificate_views.xml` | `groups=` on Issue button |
| `fusion_plating_certificates/__manifest__.py` | Version bump |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Domain widen; `_resolve_card_area` extension; new chips; KPI compute; new sort priorities |
| `fusion_plating_shopfloor/static/src/js/plant_kanban.js` | Two new KPI tiles + filter chips |
| `fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss` | New SCSS tokens (light + dark via `$o-webclient-color-scheme`) |
| `fusion_plating_shopfloor/static/src/scss/_plant_card.scss` | New `.state-awaiting_cert` / `.state-awaiting_ship` modifier classes |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump |
| `fusion_plating_quality/controllers/fp_quality_dashboard.py` | Add `certificates` block to counts response |
| `fusion_plating_quality/static/src/js/fp_quality_dashboard.js` | Sixth tab; `?tab=certificates` deep-link parsing |
| `fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml` | Sixth tab template |
| `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss` | Tab styling |
| `fusion_plating_quality/__manifest__.py` | Version bump; add `fusion_plating_certificates` to depends |
| `fusion_plating_notifications/models/fp_notification_template.py` | New `trigger_event` selection values; recipient resolver helper |
| `fusion_plating_notifications/data/fp_cert_authority_templates.xml` | NEW — seeded templates for the two events |
| `fusion_plating_notifications/__manifest__.py` | Version bump |
| `fusion_plating_quality/scripts/bt_post_shop_states.py` | NEW — battle-test script for entech smoke |
---
## Phase 1 — State machine foundation
### Task 1: Extend `fp.job.state` Selection with two new values
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py`
- [ ] **Step 1: Inspect the existing fp.job state field**
The canonical model lives in [`fusion_plating/models/fp_job.py:85-95`](../../fusion_plating/models/fp_job.py) and defines `state` with values `('draft', 'confirmed', 'in_progress', 'on_hold', 'done', 'cancelled')`. We extend in the inheriting class without rewriting the field.
- [ ] **Step 2: Add the Selection extension at the top of `FpJob` in `fusion_plating_jobs/models/fp_job.py`**
Open [`fusion_plating_jobs/models/fp_job.py`](../../fusion_plating_jobs/models/fp_job.py) and find the `class FpJob(models.Model)` block at line 34. Add this field right BEFORE the existing `x_fc_delivery_method = fields.Selection(...)` field (around line 41) so the state extension is the first declaration:
```python
# ===== Post-shop state extension (spec 2026-05-25) =================
# Two intermediate states between in_progress and done so completed
# jobs awaiting cert + shipping stay visible on the Shop Floor board.
# See docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
state = fields.Selection(
selection_add=[
('awaiting_cert', 'Awaiting Cert'),
('awaiting_ship', 'Awaiting Ship'),
],
ondelete={'awaiting_cert': 'set default', 'awaiting_ship': 'set default'},
)
```
`selection_add` appends new values WITHOUT replacing the existing Selection. `ondelete` is required when adding values to an extended Selection — it tells Odoo what to do with records in the new state if the module is uninstalled (`set default` falls back to `draft`).
- [ ] **Step 3: Verify the module still loads**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -30`
Expected: no traceback; the log ends with `Modules loaded.` or `Initiating shutdown`.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_jobs/models/fp_job.py
git commit -m "feat(fp.job): extend state with awaiting_cert + awaiting_ship
Per spec docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md.
Selection extension only; transitions wired in subsequent tasks.
"
```
---
### Task 2: Add the two auto-advance helpers on `fp.job`
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py`
- Test: `fusion_plating_jobs/tests/test_post_shop_states.py`
- [ ] **Step 1: Create the test file with a failing test for `_fp_check_advance_post_shop`**
Create [`fusion_plating_jobs/tests/test_post_shop_states.py`](../../fusion_plating_jobs/tests/test_post_shop_states.py):
```python
# -*- coding: utf-8 -*-
"""Post-shop state transitions (awaiting_cert + awaiting_ship).
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
"""
from odoo.tests.common import TransactionCase
class TestPostShopAdvance(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
def _make_job(self, state='in_progress', **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'state': state,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_advance_helper_exists(self):
job = self._make_job()
self.assertTrue(hasattr(job, '_fp_check_advance_post_shop'))
def test_advance_noop_when_state_not_in_progress(self):
# confirmed jobs should not be auto-advanced
job = self._make_job(state='confirmed')
job._fp_check_advance_post_shop()
self.assertEqual(job.state, 'confirmed')
def test_advance_noop_when_no_steps(self):
# job with zero steps stays put — nothing to evaluate
job = self._make_job(state='in_progress')
self.assertFalse(job.step_ids)
job._fp_check_advance_post_shop()
self.assertEqual(job.state, 'in_progress')
```
- [ ] **Step 2: Run the test — expect failure (method doesn't exist)**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -40`
Expected: AttributeError or test failure on `hasattr(job, '_fp_check_advance_post_shop')`.
- [ ] **Step 3: Add the helper to `fusion_plating_jobs/models/fp_job.py`**
Find a good location — near the existing `_fp_create_certificates` method (around line 2132). Insert this BEFORE it:
```python
# ==================================================================
# Post-shop auto-advance helpers (spec 2026-05-25)
# ------------------------------------------------------------------
# When the last open recipe step finishes, the job auto-advances to
# awaiting_cert (if any cert is required) or awaiting_ship (if not).
# Cert issue auto-advances awaiting_cert → awaiting_ship. Cert void
# regresses awaiting_ship → awaiting_cert. All helpers are
# idempotent — safe to call from any hook.
# ==================================================================
def _fp_check_advance_post_shop(self):
"""Auto-advance in_progress jobs whose recipe steps are all
terminal. Called from fp.job.step.button_finish post-super().
Does NOT raise — gate failures (bake/qty/QC) are surfaced by
fp.job.step.button_finish BEFORE this is called (per D12). At
this point the step IS finished and the transition is safe.
"""
for job in self:
if job.state != 'in_progress':
continue
if not job.step_ids:
continue
if any(s.state not in ('done', 'skipped', 'cancelled')
for s in job.step_ids):
continue
required = job._resolve_required_cert_types() or set()
new_state = 'awaiting_cert' if required else 'awaiting_ship'
job.state = new_state
# Side effects that used to run in button_mark_done — still
# need to fire here so cert + delivery records exist.
if new_state == 'awaiting_cert':
job._fp_create_certificates()
job._fp_fire_notification('cert_awaiting_issuance')
job._fp_schedule_cert_activity()
else:
job._fp_create_delivery()
job._fp_fire_notification('job_complete')
```
- [ ] **Step 4: Run the test — expect pass**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -40`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): add _fp_check_advance_post_shop helper
Auto-advances in_progress → awaiting_cert (or awaiting_ship if no
certs required) when every recipe step is terminal. Idempotent;
does not raise.
"
```
---
### Task 3: Add the cert-issue + cert-void transition helpers
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py`
- Modify: `fusion_plating_jobs/tests/test_post_shop_states.py`
- [ ] **Step 1: Add failing tests for both helpers**
Append to `test_post_shop_states.py`:
```python
def test_advance_after_cert_issue_helper_exists(self):
job = self._make_job()
self.assertTrue(hasattr(job, '_fp_check_advance_after_cert_issue'))
def test_regress_after_cert_void_helper_exists(self):
job = self._make_job()
self.assertTrue(hasattr(job, '_fp_check_regress_after_cert_void'))
def test_advance_after_cert_issue_idempotent_when_state_wrong(self):
# Calling on a draft job is a no-op.
job = self._make_job(state='draft')
job._fp_check_advance_after_cert_issue()
self.assertEqual(job.state, 'draft')
```
- [ ] **Step 2: Run tests — expect failure**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: failures on the three new tests.
- [ ] **Step 3: Add both helpers below `_fp_check_advance_post_shop`**
In `fusion_plating_jobs/models/fp_job.py`, immediately after the helper from Task 2:
```python
def _fp_check_advance_after_cert_issue(self):
"""Called from fp.certificate.action_issue. If every required
cert for this job is now `issued`, advance awaiting_cert →
awaiting_ship. Idempotent — safe to call repeatedly.
"""
for job in self:
if job.state != 'awaiting_cert':
continue
if 'fp.certificate' not in self.env:
continue
required = job._resolve_required_cert_types() or set()
if not required:
# Edge case: required set went empty after creation
# (e.g. partner flag toggled). Treat as "ready to ship".
job.state = 'awaiting_ship'
job._fp_create_delivery()
job._fp_resolve_cert_activities()
continue
Cert = self.env['fp.certificate'].sudo()
outstanding = Cert.search_count([
('x_fc_job_id', '=', job.id),
('certificate_type', 'in', list(required)),
('state', '!=', 'issued'),
])
if outstanding == 0:
job.state = 'awaiting_ship'
job._fp_create_delivery()
job._fp_resolve_cert_activities()
def _fp_check_regress_after_cert_void(self):
"""Called from fp.certificate.write when state=voided. If a
previously-issued cert is no longer issued, slide the job back
to awaiting_cert so it reappears in Final Inspection and the
QM is re-notified.
"""
for job in self:
if job.state != 'awaiting_ship':
continue
if 'fp.certificate' not in self.env:
continue
required = job._resolve_required_cert_types() or set()
if not required:
continue
Cert = self.env['fp.certificate'].sudo()
outstanding = Cert.search_count([
('x_fc_job_id', '=', job.id),
('certificate_type', 'in', list(required)),
('state', '!=', 'issued'),
])
if outstanding > 0:
job.state = 'awaiting_cert'
job._fp_fire_notification('cert_voided_re_notify')
job._fp_schedule_cert_activity()
```
- [ ] **Step 4: Run tests — expect pass**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: 6 tests pass.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): cert-issue + cert-void state transition helpers
_fp_check_advance_after_cert_issue: bumps awaiting_cert → awaiting_ship
when every required cert is issued.
_fp_check_regress_after_cert_void: bumps awaiting_ship → awaiting_cert
when a previously-issued cert goes back to draft/voided.
"
```
---
## Phase 2 — Step-level gating hooks
### Task 4: Hook `fp.job.step.button_finish` to gate + trigger advance
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job_step.py`
- Modify: `fusion_plating_jobs/tests/test_post_shop_states.py`
- [ ] **Step 1: Inspect the current `button_finish` in `fusion_plating_jobs/models/fp_job_step.py`**
Read the file from offset 1, find the existing `button_finish` override. We'll wrap it: pre-super gate-on-last-step check, post-super advance trigger.
- [ ] **Step 2: Append a failing test**
```python
def test_button_finish_on_last_step_triggers_advance(self):
"""End-to-end: finishing the only step of an in_progress job
flips state to awaiting_cert (cert required) or awaiting_ship
(no cert required)."""
# Skip if recipe-step infra isn't loaded
if 'fp.job.step' not in self.env:
self.skipTest('fp.job.step not available')
job = self._make_job(state='in_progress')
step = self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Final Inspection',
'state': 'in_progress',
'sequence': 10,
})
# No certs required (partner has no flags) → awaiting_ship
step.button_finish()
self.assertEqual(job.state, 'awaiting_ship')
```
- [ ] **Step 3: Run — expect fail**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: `assertEqual(job.state, 'awaiting_ship')` fails — state stays `in_progress`.
- [ ] **Step 4: Wrap `button_finish` in `fusion_plating_jobs/models/fp_job_step.py`**
Locate the existing `def button_finish` override (or add one if absent — file has an `_inherit = 'fp.job.step'` class). Add this method (or merge into existing):
```python
def button_finish(self):
"""Wrap super() with two behaviors:
1. PRE-super: if this finish would terminalize every step on the
job AND job is in_progress, run the bake/qty/QC gates that
used to live in fp.job.button_mark_done. Failure surfaces as
UserError on this click — operator fixes + retries.
2. POST-super: call job._fp_check_advance_post_shop() so the
state auto-advances cleanly.
"""
# Pre-super: gate on last-step finish per spec D12.
for step in self:
if step.state not in ('in_progress', 'paused', 'ready'):
continue
job = step.job_id
if not job or job.state != 'in_progress':
continue
# Would this finish leave every step terminal?
siblings_open = job.step_ids.filtered(
lambda s: s.id != step.id
and s.state not in ('done', 'skipped', 'cancelled')
)
if siblings_open:
continue # not the last open step — skip the gates
# Run the gates that used to live in button_mark_done.
# Each raises UserError on failure; operator stays on the
# step, fixes the issue, retries the click. Manager bypasses
# via the same context flags as before.
job._fp_check_finish_gates()
result = super().button_finish()
# Post-super: trigger advance for any jobs whose step-finish
# left them all-terminal.
jobs = self.mapped('job_id')
for job in jobs:
job._fp_check_advance_post_shop()
return result
```
- [ ] **Step 5: Add the `_fp_check_finish_gates` helper to `fusion_plating_jobs/models/fp_job.py`**
Right before `_fp_check_advance_post_shop`, add:
```python
def _fp_check_finish_gates(self):
"""Run the bake-window / qty-reconciliation / QC gates that
used to live in button_mark_done. Called from
fp.job.step.button_finish when the operator is finishing the
LAST open step on the job.
Raises UserError on failure — operator stays on the step, fixes
the issue, retries the click. Manager bypass via the same
context flags as button_mark_done.
"""
self.ensure_one()
# Re-uses the existing button_mark_done body for gate side
# effects. The state transition + cert/delivery side effects
# are NOT triggered here — they fire from
# _fp_check_advance_post_shop after super().button_finish.
# Set the no-side-effect context to short-circuit the state
# flip + create_delivery/create_certificates blocks below the
# gates in button_mark_done.
try:
self.with_context(
fp_check_gates_only=True,
).button_mark_done()
except UserError:
raise
```
- [ ] **Step 6: Tighten `button_mark_done` to honor `fp_check_gates_only`**
In `fusion_plating_jobs/models/fp_job.py`, find the existing `button_mark_done` method (around line 1799). After all gates pass and BEFORE `job.state = 'done'` (line 1972), add:
```python
# When called as a gate-check from fp.job.step.button_finish
# (per spec D12), exit BEFORE flipping state — the post-shop
# advance helper handles the actual transition.
if self.env.context.get('fp_check_gates_only'):
continue
```
- [ ] **Step 7: Run the test — expect pass**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: all tests pass; the new test confirms state → `awaiting_ship`.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating_jobs/models/fp_job_step.py fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job.step): wrap button_finish with gate + advance
Pre-super: when finishing the last open step on an in_progress job,
run the bake/qty/QC gates from button_mark_done so failures surface as
UserError on the click. Post-super: trigger _fp_check_advance_post_shop
so the state auto-advances cleanly. Spec D12.
"
```
---
### Task 5: Add `button_mark_shipped` and repurpose `button_mark_done`
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py`
- Modify: `fusion_plating_jobs/views/fp_job_views.xml`
- Modify: `fusion_plating_jobs/tests/test_post_shop_states.py`
- [ ] **Step 1: Add a failing test for the new button**
Append to `test_post_shop_states.py`:
```python
def test_button_mark_shipped_requires_awaiting_ship(self):
from odoo.exceptions import UserError
job = self._make_job(state='in_progress')
with self.assertRaises(UserError):
job.button_mark_shipped()
def test_button_mark_shipped_from_awaiting_ship_lands_done(self):
job = self._make_job(state='awaiting_ship')
job.button_mark_shipped()
self.assertEqual(job.state, 'done')
self.assertTrue(job.date_finished)
```
- [ ] **Step 2: Run — expect fail**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: `AttributeError: 'fp.job' object has no attribute 'button_mark_shipped'`.
- [ ] **Step 3: Add `button_mark_shipped` to `fusion_plating_jobs/models/fp_job.py`**
Right after `button_mark_done`:
```python
def button_mark_shipped(self):
"""Manual transition awaiting_ship → done. Operator-facing
button on the job form; restricted to Manager / Owner via
groups= on the view button.
Does NOT run the bake/qty/QC gates — those passed when the job
first transitioned to awaiting_cert (or awaiting_ship if no
certs were required). This is just the "yes, shipped" stamp.
Future hook: delivery.action_mark_delivered will call this
automatically — out of scope for this iteration.
"""
for job in self:
if job.state != 'awaiting_ship':
raise UserError(_(
'Job %s cannot be marked Shipped — state is "%s" '
'(expected "awaiting_ship").'
) % (job.name, job.state))
job.state = 'done'
job.date_finished = fields.Datetime.now()
job._fp_fire_notification('job_shipped')
job.message_post(body=_(
'Marked shipped by %s.'
) % self.env.user.name)
return True
```
- [ ] **Step 4: Run — expect pass**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30`
Expected: 2 new tests pass.
- [ ] **Step 5: Add the form-view button**
Open [`fusion_plating_jobs/views/fp_job_views.xml`](../../fusion_plating_jobs/views/fp_job_views.xml). Find the existing `Mark Done` button (search for `name="button_mark_done"`). Add a NEW button right after it (do not remove `button_mark_done` — it's used by `_fp_check_finish_gates` internally):
```xml
```
And gate the existing `Mark Done` button to be invisible (legacy paths still call it programmatically, but operators don't click it):
```xml
```
- [ ] **Step 6: Reload the module and confirm view loads**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -20`
Expected: no view-load errors.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/views/fp_job_views.xml fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): button_mark_shipped + repurpose button_mark_done
Mark Shipped is the new operator-facing button (Manager/Owner only,
visible from awaiting_ship). button_mark_done stays as an internal
method called by _fp_check_finish_gates; view-level button hidden.
"
```
---
## Phase 3 — Certificate-side hooks + ACL
### Task 6: Add the ACL guard to `fp.certificate.action_issue`
**Files:**
- Modify: `fusion_plating_certificates/models/fp_certificate.py`
- [ ] **Step 1: Read the current `action_issue` (around line 422 of fp_certificate.py)**
Confirm the method signature is `def action_issue(self)` with a `for rec in self` loop.
- [ ] **Step 2: Inject the guard at the top of the for loop**
In [`fusion_plating_certificates/models/fp_certificate.py`](../../fusion_plating_certificates/models/fp_certificate.py), find line 423 (`for rec in self:`). Inject the guard right BEFORE the existing `if rec.state != 'draft'` check:
```python
# ===== ACL guard (spec 2026-05-25 §ACL changes) ==============
# Only QM / Manager / Owner can issue certificates. Two-layer
# enforcement; view-level groups= on the button is the other
# layer. Manager bypass via context for cron / scripted issuance.
if not self.env.context.get('fp_skip_cert_authority_gate'):
cert_authority_gids = []
for xmlid in (
'fusion_plating.group_fp_quality_manager',
'fusion_plating.group_fp_manager',
'fusion_plating.group_fp_owner',
):
grp = self.env.ref(xmlid, raise_if_not_found=False)
if grp:
cert_authority_gids.append(grp.id)
if cert_authority_gids and not (
set(self.env.user.all_group_ids.ids)
& set(cert_authority_gids)
):
from odoo.exceptions import AccessError
raise AccessError(_(
'Only Quality Managers, Managers, and Owners can '
'issue certificates. Ask your QM to review and '
'issue this CoC.'
))
else:
from markupsafe import Markup
self.message_post(body=Markup(_(
'Cert authority gate bypassed by '
'%(u)s (context flag fp_skip_cert_authority_gate).'
)) % {'u': self.env.user.name})
for rec in self:
```
Wait — we're INSIDE the for loop already. Re-read carefully: the guard goes BEFORE the loop. Find line 422 `def action_issue(self):` and the line 423 `for rec in self:`. Insert the guard block BETWEEN these two lines (so it runs once per call, not per record).
After insertion the structure should be:
```python
def action_issue(self):
# === ACL guard (the block above) ===
for rec in self:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
# ...existing body...
```
- [ ] **Step 3: Verify the module reloads**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20`
Expected: clean load.
- [ ] **Step 4: Smoke-test the guard manually via odoo-shell**
Run:
```bash
docker exec -i odoo-modsdev-app bash -c "echo \"
cert = env['fp.certificate'].search([], limit=1)
if cert:
print('Found cert:', cert.name, 'state:', cert.state)
# Try as a non-manager user (use a Technician if present)
tech = env['res.users'].search([
('all_group_ids', 'in', env.ref('fusion_plating.group_fp_technician').id),
('share', '=', False),
], limit=1)
if tech:
print('Trying as Technician:', tech.name)
try:
cert.with_user(tech).action_issue()
print('FAIL: should have raised AccessError')
except Exception as e:
print('OK guard raised:', type(e).__name__, str(e)[:100])
\" | odoo shell -c /etc/odoo/odoo.conf -d modsdev --no-http"
```
Expected output: `OK guard raised: AccessError ...Only Quality Managers, Managers, and Owners...`
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_certificates/models/fp_certificate.py
git commit -m "feat(fp.certificate): ACL guard on action_issue
QM/Manager/Owner only. Two-layer enforcement; view-level groups= in
next task. Manager bypass via fp_skip_cert_authority_gate context flag
with chatter audit. Per spec 2026-05-25.
"
```
---
### Task 7: Hook cert state changes back into the job state machine
**Files:**
- Modify: `fusion_plating_certificates/models/fp_certificate.py`
- [ ] **Step 1: Add the post-issue callback at the END of `action_issue`**
In `fusion_plating_certificates/models/fp_certificate.py`, locate the end of `action_issue` — after `rec.state = 'issued'` and the existing PDF render + attachment work, add:
```python
# Post-issue: ask the job to check whether ALL required
# certs are now issued. If so it'll auto-advance to
# awaiting_ship and resolve the QM activity. Spec 2026-05-25.
if 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
rec.x_fc_job_id._fp_check_advance_after_cert_issue()
```
(Place this inside the `for rec in self:` loop, after all existing state-flip work.)
- [ ] **Step 2: Add a `write` override that detects voiding**
After the `action_issue` method, add:
```python
def write(self, vals):
"""Override to detect cert voiding and trigger the job state
regress (awaiting_ship → awaiting_cert). Spec 2026-05-25.
"""
was_issued = {}
if 'state' in vals and vals['state'] == 'voided':
was_issued = {
rec.id: (rec.state == 'issued')
for rec in self
}
result = super().write(vals)
if was_issued:
for rec in self:
if not was_issued.get(rec.id):
continue # wasn't issued to begin with — no regress
if 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
rec.x_fc_job_id._fp_check_regress_after_cert_void()
return result
```
- [ ] **Step 3: Reload and verify clean load**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20`
Expected: clean load.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_certificates/models/fp_certificate.py
git commit -m "feat(fp.certificate): wire state changes back to fp.job
action_issue calls job._fp_check_advance_after_cert_issue so the job
auto-advances to awaiting_ship when all certs are issued.
write({'state':'voided'}) calls job._fp_check_regress_after_cert_void
so the job slides back to awaiting_cert and the QM is re-notified.
"
```
---
### Task 8: Add `x_fc_age_hours` computed field + view-level groups gating
**Files:**
- Modify: `fusion_plating_certificates/models/fp_certificate.py`
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml`
- Modify: `fusion_plating_certificates/__manifest__.py`
- [ ] **Step 1: Add the field to `fp.certificate`**
In `fusion_plating_certificates/models/fp_certificate.py`, near the other compute fields (e.g. around the `_compute_reading_stats` block), add:
```python
x_fc_age_hours = fields.Float(
string='Age (hours)',
compute='_compute_x_fc_age_hours',
help='Hours since the cert was created. Drives the Quality '
'Dashboard age chip and overdue filter.',
)
def _compute_x_fc_age_hours(self):
from datetime import datetime
now = datetime.now()
for rec in self:
if not rec.create_date:
rec.x_fc_age_hours = 0.0
continue
delta = now - rec.create_date
rec.x_fc_age_hours = delta.total_seconds() / 3600.0
```
- [ ] **Step 2: Add `groups=` to the Issue button**
In [`fusion_plating_certificates/views/fp_certificate_views.xml`](../../fusion_plating_certificates/views/fp_certificate_views.xml), find the Issue button (search for `name="action_issue"`). Add the `groups=` attribute:
```xml
```
(Preserve existing attributes; only add `groups=`.)
- [ ] **Step 3: Bump version in `fusion_plating_certificates/__manifest__.py`**
Find the existing `version` line. Bump to `19.0.6.0.0` (major bump for the ACL + state changes per spec).
- [ ] **Step 4: Reload + verify**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20`
Expected: clean load; Issue button hidden for non-authority users.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_certificates/
git commit -m "feat(fp.certificate): x_fc_age_hours + view-level Issue gating
x_fc_age_hours: non-stored Float, drives Quality Dashboard age chip +
overdue filter.
View-level groups= on Issue button so non-QMs don't even see it.
Version bump 19.0.6.0.0.
"
```
---
## Phase 4 — Plant Kanban visibility
### Task 9: Widen the kanban domain + extend `_resolve_card_area`
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py`
- [ ] **Step 1: Widen the domain**
In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py) around line 73, replace:
```python
domain = [
('state', 'in', ('confirmed', 'in_progress')),
]
```
with:
```python
domain = [
('state', 'in', ('confirmed', 'in_progress',
'awaiting_cert', 'awaiting_ship')),
]
```
Also update the comment block above (lines 69-72) to reflect spec 2026-05-25:
```python
# Base domain — in-flight jobs.
# 2026-05-25 (spec post-shop-cert-shipping-job-states): awaiting_cert
# + awaiting_ship are included so completed-but-uncertified /
# ready-to-ship jobs stay visible in the Final inspection /
# Shipping columns.
```
- [ ] **Step 2: Extend `_resolve_card_area`**
Around line 165, find the `_resolve_card_area` function. Replace its body with:
```python
def _resolve_card_area(job):
"""Pick the column a card lives in.
Priority (spec 2026-05-25):
1. no_parts cards → Receiving (the receiver acts there)
2. awaiting_cert → Final inspection (state drives column)
3. awaiting_ship → Shipping (state drives column)
4. active step's area_kind (in_progress jobs with live work)
5. orphan fallback → Receiving
See specs 2026-05-24-shopfloor-live-step-fix-design.md Change 4
and 2026-05-25-post-shop-cert-shipping-job-states-design.md.
"""
if job.card_state == 'no_parts':
return 'receiving'
if job.state == 'awaiting_cert':
return 'inspection'
if job.state == 'awaiting_ship':
return 'shipping'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
return 'receiving' # orphan fallback
```
- [ ] **Step 3: Reload + smoke test**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20`
Expected: clean load.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_shopfloor/controllers/plant_kanban.py
git commit -m "feat(plant_kanban): include awaiting_cert + awaiting_ship
Widens the domain so completed-but-uncertified / ready-to-ship jobs
stay visible. _resolve_card_area pins them to Final inspection /
Shipping columns regardless of (now-empty) active_step_id.
"
```
---
### Task 10: Add new card_state values, chips, and sort priorities
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py`
- Modify: `fusion_plating_jobs/models/fp_job.py`
- [ ] **Step 1: Add the two new entries to `_SORT_PRIORITY` in plant_kanban.py**
Around line 36-50 in `fusion_plating_shopfloor/controllers/plant_kanban.py`, find `_SORT_PRIORITY`. Add the two new entries (use floats — sort_key returns a tuple and float comparison is fine):
```python
_SORT_PRIORITY = {
'on_hold': 0,
'no_parts': 1,
'bake_due': 2,
'awaiting_signoff': 3,
'awaiting_cert': 3.5, # NEW — sit right after awaiting_signoff
'awaiting_qc': 4,
'ready_mine': 5,
'running_mine': 6,
'ready': 7,
'running': 8,
'awaiting_ship': 8.5, # NEW — after running, before idle
'idle_warning': 9,
'predecessor_locked': 10,
'contract_review': 11,
'done': 12,
}
```
- [ ] **Step 2: Extend `_state_chip` for the two new states**
Find `_state_chip()` around line 293. Add the two new branches BEFORE the `if card_state == 'done':` line:
```python
if card_state == 'awaiting_cert':
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
if card_state == 'awaiting_ship':
return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
```
- [ ] **Step 3: Add the card_state values to `_compute_card_state` in fusion_plating_jobs/models/fp_job.py**
Find `_compute_card_state` around line 262 in `fusion_plating_jobs/models/fp_job.py`. Inside the `for job in self:` loop, BEFORE the existing `# Rule 8 — done` block (around line 315), add:
```python
# Rule 7.5 — awaiting_cert + awaiting_ship (spec 2026-05-25)
# State drives card_state here regardless of step state.
if job.state == 'awaiting_cert':
job.card_state = 'awaiting_cert'
continue
if job.state == 'awaiting_ship':
job.card_state = 'awaiting_ship'
continue
```
ALSO update the `@api.depends` decorator above `_compute_card_state` (line 252) to include `'state'`:
```python
@api.depends(
'state',
'active_step_id',
...
)
```
(The existing decorator already lists `'state'` — verify but no change needed if so.)
- [ ] **Step 4: Reload + verify**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20`
Expected: clean load.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_shopfloor/controllers/plant_kanban.py fusion_plating_jobs/models/fp_job.py
git commit -m "feat(card_state): awaiting_cert + awaiting_ship chips + sort
New card_state values mirror the new fp.job.state values; chips
read 'Awaiting CoC' / 'Ready to ship'. Sort priorities slot them
right after awaiting_signoff / running.
"
```
---
### Task 11: Add the SCSS tokens + state modifier classes
**Files:**
- Modify: `fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss`
- Modify: `fusion_plating_shopfloor/static/src/scss/_plant_card.scss`
- [ ] **Step 1: Add tokens to `_plant_tokens.scss` (light + dark)**
Open [`fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss`](../../fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss). Find the existing `$_state-*` hex declarations and add light values:
```scss
// Spec 2026-05-25 — post-shop states
$_state-awaiting-cert-bg-hex: #fff3cd;
$_state-awaiting-cert-bdr-hex: #ff9800;
$_state-awaiting-ship-bg-hex: #d1f1d4;
$_state-awaiting-ship-bdr-hex: #2e7d32;
```
Find the existing `@if $o-webclient-color-scheme == dark { ... !global; }` block. Add dark overrides INSIDE it:
```scss
@if $o-webclient-color-scheme == dark {
// ...existing dark overrides...
$_state-awaiting-cert-bg-hex: #3a2f15 !global;
$_state-awaiting-cert-bdr-hex: #ffb74d !global;
$_state-awaiting-ship-bg-hex: #1a2d1f !global;
$_state-awaiting-ship-bdr-hex: #66bb6a !global;
}
```
Then expose them as CSS-custom-prop-backed SCSS vars (mirror the existing pattern, search for one to copy):
```scss
$plant-state-awaiting-cert-bg: var(--fp-state-awaiting-cert-bg, $_state-awaiting-cert-bg-hex);
$plant-state-awaiting-cert-bdr: var(--fp-state-awaiting-cert-bdr, $_state-awaiting-cert-bdr-hex);
$plant-state-awaiting-ship-bg: var(--fp-state-awaiting-ship-bg, $_state-awaiting-ship-bg-hex);
$plant-state-awaiting-ship-bdr: var(--fp-state-awaiting-ship-bdr, $_state-awaiting-ship-bdr-hex);
```
- [ ] **Step 2: Add state-modifier classes to `_plant_card.scss`**
Open [`fusion_plating_shopfloor/static/src/scss/_plant_card.scss`](../../fusion_plating_shopfloor/static/src/scss/_plant_card.scss). Find the existing `.o_fp_plant_card.state-*` classes (search for `&.state-`). Add the two new modifier classes following the established pattern:
```scss
&.state-awaiting_cert {
background-color: $plant-state-awaiting-cert-bg;
border-left: 4px solid $plant-state-awaiting-cert-bdr;
}
&.state-awaiting_ship {
background-color: $plant-state-awaiting-ship-bg;
border-left: 4px solid $plant-state-awaiting-ship-bdr;
}
```
- [ ] **Step 3: Bust the asset cache + reload**
Run:
```bash
docker exec odoo-modsdev-db psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20
```
Expected: clean load.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_shopfloor/static/src/scss/
git commit -m "feat(plant kanban): SCSS tokens + classes for awaiting_cert/ship
Light + dark variants via the existing \$o-webclient-color-scheme
compile-time branch (per Rule 9). amber for awaiting_cert, green for
awaiting_ship. Border-left 4px to match the existing state pattern.
"
```
---
### Task 12: Add KPI tiles + filter chips + KPI server compute
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py`
- Modify: `fusion_plating_shopfloor/static/src/js/plant_kanban.js`
- [ ] **Step 1: Server-side: extend the `kpis` dict around line 124**
In `fusion_plating_shopfloor/controllers/plant_kanban.py`, find the `kpis = {...}` dict. Add two entries:
```python
kpis = {
'active_jobs': sum(1 for j in jobs if j.state != 'done'),
'at_my_station': sum(
1 for j in jobs
if j.card_state in ('ready_mine', 'running_mine')
),
'bakes_due_soon': sum(
1 for j in jobs if j.card_state == 'bake_due'
),
'on_hold': sum(
1 for j in jobs if j.card_state == 'on_hold'
),
'awaiting_cert': sum(
1 for j in jobs if j.state == 'awaiting_cert'
),
'awaiting_ship': sum(
1 for j in jobs if j.state == 'awaiting_ship'
),
'overdue': sum(
1 for j in jobs
if j.date_deadline and j.date_deadline.date() < date.today()
and j.state != 'done'
),
}
```
- [ ] **Step 2: Server-side: extend the filter logic around line 76**
Find the `filters = filters or {}` block. Add two new filter clauses:
```python
if filters.get('awaiting_cert'):
domain.append(('state', '=', 'awaiting_cert'))
if filters.get('awaiting_ship'):
domain.append(('state', '=', 'awaiting_ship'))
```
- [ ] **Step 3: Client-side: extend KPI tiles in plant_kanban.js**
Open [`fusion_plating_shopfloor/static/src/js/plant_kanban.js`](../../fusion_plating_shopfloor/static/src/js/plant_kanban.js). Find the KPI tile array (search for `at_my_station` or the existing KPI definitions). Add two entries following the existing pattern (likely a `_t()` label + key + kind):
```javascript
{ key: 'awaiting_cert', label: _t('Awaiting CoC'),
count: kpis.awaiting_cert, kind: 'awaiting_cert' },
{ key: 'awaiting_ship', label: _t('Ready to Ship'),
count: kpis.awaiting_ship, kind: 'awaiting_ship' },
```
(If the existing structure uses a different shape, mirror it — don't invent new field names.)
- [ ] **Step 4: Client-side: extend filter chips**
In the same file, find the filter chip definitions (search for `running`, `blocked`, `overdue`, `fair` chips). Add two:
```javascript
{ key: 'awaiting_cert', label: _t('Awaiting CoC') },
{ key: 'awaiting_ship', label: _t('Ready to Ship') },
```
- [ ] **Step 5: Bust asset cache + reload**
Run:
```bash
docker exec odoo-modsdev-db psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20
```
Expected: clean load.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_shopfloor/
git commit -m "feat(plant kanban): KPI tiles + filter chips for new states
Two new clickable KPI tiles ('Awaiting CoC' / 'Ready to Ship') and
two matching filter chips. Server-side filter clauses honor the
state values. Server-side KPI counts.
"
```
---
### Task 13: Update `_compute_mini_timeline_json` for the two new states
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py`
- [ ] **Step 1: Read the current `_compute_mini_timeline_json` (around line 339-370)**
Confirm the current shape: for each of 9 areas, emits `{area, state, variant?}`.
- [ ] **Step 2: Extend the compute to handle the two new states**
Replace the body of `_compute_mini_timeline_json` with this updated version:
```python
@api.depends(
'step_ids.state',
'step_ids.area_kind',
'active_step_id',
'card_state',
'state',
)
def _compute_mini_timeline_json(self):
"""9-element JSON array, one per Shop Floor column.
For awaiting_cert / awaiting_ship (spec 2026-05-25): the
Final-inspection or Shipping dot renders as 'current' with the
state-named variant; all earlier dots render 'done'.
"""
for job in self:
# Post-shop state override: visually walk the card across
# the two right-most columns even though the recipe may not
# have steps with those area_kinds.
if job.state == 'awaiting_cert':
timeline = []
for area in _COLUMN_SEQUENCE:
if area == 'inspection':
timeline.append({
'area': area,
'state': 'current',
'variant': 'awaiting_cert',
})
elif area == 'shipping':
timeline.append({'area': area, 'state': 'upcoming'})
else:
timeline.append({'area': area, 'state': 'done'})
job.mini_timeline_json = json.dumps(timeline)
continue
if job.state == 'awaiting_ship':
timeline = []
for area in _COLUMN_SEQUENCE:
if area == 'shipping':
timeline.append({
'area': area,
'state': 'current',
'variant': 'awaiting_ship',
})
else:
timeline.append({'area': area, 'state': 'done'})
job.mini_timeline_json = json.dumps(timeline)
continue
# Standard path — pre-existing logic.
active_area = (job.active_step_id.area_kind
if job.active_step_id else None)
timeline = []
for area in _COLUMN_SEQUENCE:
steps_in_area = job.step_ids.filtered(
lambda s: s.area_kind == area,
)
if not steps_in_area:
timeline.append({'area': area, 'state': 'upcoming'})
continue
if all(s.state in ('done', 'skipped') for s in steps_in_area):
timeline.append({'area': area, 'state': 'done'})
elif area == active_area:
timeline.append({
'area': area,
'state': 'current',
'variant': job.card_state or '',
})
else:
timeline.append({'area': area, 'state': 'upcoming'})
job.mini_timeline_json = json.dumps(timeline)
```
- [ ] **Step 3: Reload + verify**
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -20`
Expected: clean load.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_jobs/models/fp_job.py
git commit -m "feat(mini_timeline): walk the dot for awaiting_cert/ship
awaiting_cert → all left dots done, inspection current, shipping upcoming.
awaiting_ship → all left dots done, shipping current.
Variant carries the card_state so the dot tinting matches the chip.
"
```
---
## Phase 5 — Quality Dashboard "Certificates" tab
### Task 14: Add the `certificates` block to the counts endpoint
**Files:**
- Modify: `fusion_plating_quality/controllers/fp_quality_dashboard.py`
- [ ] **Step 1: Extend the endpoint**
In [`fusion_plating_quality/controllers/fp_quality_dashboard.py`](../../fusion_plating_quality/controllers/fp_quality_dashboard.py), find the `counts` method (line 16). At the top of the method add:
```python
Cert = env['fp.certificate'] if 'fp.certificate' in env else None
```
In the returned dict, add `certificates` as the LAST block:
```python
'certificates': ({
'open': Cert.search_count([('state', '=', 'draft')]),
'overdue': Cert.search_count([
('state', '=', 'draft'),
('create_date', '<', d1),
]),
} if Cert is not None else {'open': 0, 'overdue': 0}),
```
- [ ] **Step 2: Update the docstring**
Append to the existing docstring (lines 17-26):
```
- Certificate: state='draft' for > 1 day = overdue
```
- [ ] **Step 3: Verify the endpoint responds**
Run an HTTP smoke test via odoo-shell:
```bash
docker exec -i odoo-modsdev-app bash -c "echo \"
controller = env.registry['fp.quality.dashboard']
\" | odoo shell -c /etc/opp/odoo.conf -d modsdev --no-http"
```
OR simpler: reload and rely on UI test in Task 15.
Run: `docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_quality --stop-after-init 2>&1 | tail -10`
Expected: clean load.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_quality/controllers/fp_quality_dashboard.py
git commit -m "feat(quality_dashboard): add certificates block to counts
Open = draft certs. Overdue = draft and create_date > 24h.
Falls back to {open:0, overdue:0} when fp.certificate isn't installed.
"
```
---
### Task 15: Add the sixth tab to the Quality Dashboard OWL component
**Files:**
- Modify: `fusion_plating_quality/static/src/js/fp_quality_dashboard.js`
- Modify: `fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml`
- Modify: `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss`
- [ ] **Step 1: Read the existing dashboard to understand its tab pattern**
Read [`fusion_plating_quality/static/src/js/fp_quality_dashboard.js`](../../fusion_plating_quality/static/src/js/fp_quality_dashboard.js) (first 80 lines) and [`fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml`](../../fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml) (first 80 lines). Identify how the existing 5 tabs are declared.
- [ ] **Step 2: Add the Certificates tab to the JS state**
In `fp_quality_dashboard.js`, find where the tab list / tab state is defined. The existing tabs likely look like `['holds', 'checks', 'ncrs', 'capas', 'rmas']`. Add `'certificates'` to the end:
```javascript
const TABS = ['holds', 'checks', 'ncrs', 'capas', 'rmas', 'certificates'];
```
(Adjust to match the existing pattern — if it's an object keyed by tab name, follow that.)
Also support the URL deep-link `?tab=certificates` in `setup()`:
```javascript
setup() {
// ...existing setup...
// Spec 2026-05-25 — honor ?tab= query param from notification deep-links
const params = this.action?.context?.params || {};
const initialTab = params.tab || 'holds';
if (TABS.includes(initialTab)) {
this.state.activeTab = initialTab;
}
}
```
- [ ] **Step 3: Add the tab row + content to the XML template**
In `fp_quality_dashboard.xml`, find the existing tab row (search for `t-on-click="() => this.setTab('holds')"` or similar). Add a sixth `