diff --git a/fusion_plating/docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md b/fusion_plating/docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md new file mode 100644 index 00000000..837cfbd5 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md @@ -0,0 +1,610 @@ +# Post-Shop Cert + Shipping Job States + +**Date:** 2026-05-25 +**Status:** Approved for implementation (brainstorming gate) +**Author:** Brainstorming session (gsinghpal) +**Triggering incident:** Job WO-30058 (SO-30058) finished all recipe steps on entech and **disappeared from the Shop Floor kanban**. CoC was auto-spawned in `draft` but nobody was notified, no surface listed pending certs for the Quality Manager, and there was no kanban column for "completed-but-not-shipped" jobs. Operators reported jobs they had finished feeling "lost" — same risk that a job could leave the building without a CoC. + +## Goal + +Three things, decided as one unit of work: + +1. **Stop completed-but-uncertified jobs from disappearing** from the Plant Kanban — they must stay visible to shop staff so jobs aren't forgotten or shipped without paperwork. +2. **Give the Quality Manager a dedicated surface** (Quality Dashboard tab + email + in-app activity) for CoC issuance, with hard ACL gating so Technicians cannot self-issue. +3. **Model the actual lifecycle** in `fp.job.state` so reporting, queries, and future workflow gates derive from a single source of truth instead of cross-module joins. + +## Out of scope (deferred to follow-on work) + +- **Shipping label printing, carrier dispatch, tracking-number capture, BoL generation.** These remain manual for now; the new `awaiting_ship` state is a parking column so jobs are visible to the shipping crew. `awaiting_ship → done` is a manual button click. (User: *"lets first finish this certification step then we will look into shipping"*.) +- **Auto-transition from `awaiting_ship → done` on `delivery.action_mark_delivered`.** Scaffolded as a future hook in the design; not wired in this scope. +- **RMA-aware regression** (job re-opening on RMA receive). Already handled by existing RMA flow — not touched here. +- **Per-cert-type ACL** (e.g. only QM can issue Nadcap, but Manager can issue CoC). Out of scope; single QM-or-higher gate for all cert types in v1. + +## Decisions reached during brainstorming + +| # | Decision | Rationale | +|---|---|---| +| D1 | Use **Approach A** — add two new `fp.job.state` values (`awaiting_cert`, `awaiting_ship`) | Cleanest semantics; `state='done'` will once again mean "fully complete and shipped". Single source of truth for kanban, dashboard, reporting, and future delivery automation. | +| D2 | **Auto-advance** `in_progress → awaiting_cert` when every recipe step is terminal AND a cert is required | No new operator button; removes risk of forgetting to advance. Hooks into existing `all_steps_terminal` computed field. | +| D3 | **Auto-advance** `awaiting_cert → awaiting_ship` from `fp.certificate.action_issue` when every required cert is `issued` | The QM clicking Issue is the natural trigger; no separate "mark inspection complete" button needed. The recipe's final-inspection step already captures inspection data via custom prompts. | +| D4 | **Cert void regresses state** (`awaiting_ship → awaiting_cert` if a previously-issued cert is voided) | Defensive against late-discovered issues. Re-fires `cert_awaiting_issuance` notification under a `cert_voided_re_notify` event so dedupe doesn't suppress it. | +| D5 | **Manual `awaiting_ship → done` button** in this scope; auto-hook on delivery deferred | User explicitly scoped shipping work out. The button is repurposed from the existing milestone-advance "Mark Done" button (renamed "Mark Shipped"), restricted to Manager/Owner. | +| D6 | **ACL gate on `action_issue`** — Manager / Quality Manager / Owner only | User: *"certifications can only be issued by Manager, Quality Manager and Owner, i don't want technicians to issue certifications"*. Two-layer enforcement: Python `AccessError` + view-level `groups=` on the Issue button. | +| D7 | **Group-membership resolved via `all_group_ids`** (transitive) — Owners reach QM authority via implication chain | Rule 23 (CLAUDE.md). Owners don't carry `group_fp_quality_manager` directly; the `all_group_ids` lookup catches them via implication. | +| D8 | **Notification fires on the auto-transition**, not on the operator's last step-finish click | Decouples notification from operator UX; transition is the authoritative trigger so all paths (UI, RPC, scripted) notify consistently. | +| D9 | **Belt-and-suspenders: in-app `mail.activity`** in addition to email | If email bounces / spam-folders, the red activity badge on the QM's home is the floor-of-truth signal. Activity auto-resolves on `awaiting_ship` transition. | +| D10 | **Jobs that don't require certs skip `awaiting_cert`** and land directly in `awaiting_ship` | Visibility consistency — every completed-but-unshipped job is in the Shipping column, regardless of cert requirements. No silent path to `done`. | +| D11 | **Plant kanban shows the two new states in `Final inspection` and `Shipping` columns**; old per-step `area_kind` logic untouched for `in_progress` | Repurposes the two right-most columns that are currently almost always empty. State drives column for the new values; nothing else changes. | +| D12 | **Gates from `button_mark_done` (bake/qty/QC) move UP into `fp.job.step.button_finish` on the LAST step** instead of running at auto-transition time | The auto-transition itself must not raise — if it raised when the operator finishes their last step, the step would finish but the auto-advance would silently fail with no error path. Better: when finishing the last step, surface the pending gates as UserError on the finish click. Operator fixes (qty, bake, QC), retries finish, transition fires cleanly. Step-completion gate is implied (the step IS finishing). | + +## Architecture + +``` +┌─ STATE MACHINE ─────────────────────────────────────────────────┐ +│ │ +│ confirmed → in_progress → awaiting_cert → awaiting_ship → done│ +│ │ ▲ ▲ ▲ │ +│ │ │ │ │ │ +│ all steps terminal QM Issue (last cert) Mark Shipped│ │ +│ + cert required button │ │ +│ │ │ │ +│ └──── (no cert required) ─────────────────┘ │ +│ │ +│ awaiting_ship ──(cert voided after issue)──► awaiting_cert +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ KANBAN VISIBILITY (PLANT VIEW) ────────────────────────────────┐ +│ │ +│ Receiving · Masking · Blasting · Racking · Plating │ +│ · Baking · De-Racking · ★Final inspection · ★Shipping │ +│ ▲ ▲ │ +│ │ │ │ +│ awaiting_cert awaiting_ship │ +│ (column = state, not active_step) │ +│ │ +│ Domain widens: state IN (confirmed, in_progress, │ +│ awaiting_cert, awaiting_ship) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ QUALITY DASHBOARD ─────────────────────────────────────────────┐ +│ │ +│ Holds | Checks | NCRs | CAPAs | RMAs | ★Certificates │ +│ (new 6th tab) │ +│ │ +│ Tab content: kanban grouped by state, Draft folded open, │ +│ filters (My Customer / Today / Overdue >24h / │ +│ Missing Fischerscope), buttons Open Cert / Open Job. │ +│ │ +│ Header KPI: "Certificates Awaiting Issuance: N (M overdue)" │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ NOTIFICATION (when state hits awaiting_cert) ──────────────────┐ +│ │ +│ Email via fp.notification.template, event: │ +│ cert_awaiting_issuance (first notification) │ +│ cert_voided_re_notify (re-fires after cert void) │ +│ │ +│ Recipients: every active non-share user with all_group_ids │ +│ containing QM | Manager | Owner. Resolved by helper │ +│ _fp_resolve_cert_authority_users() (see Rule 13l pattern). │ +│ │ +│ + mail.activity ("To Do") assigned to one QM, round-robin │ +│ by last_activity_at. Auto-marks done on awaiting_ship. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Schema changes (additive) + +### `fp.job` model + +| Change | Type | Notes | +|---|---|---| +| `state` selection — add `('awaiting_cert', 'Awaiting Cert')` and `('awaiting_ship', 'Awaiting Ship')` | Selection extension | Selection extension via `_inherit` keeps tracking + chatter audit working. Sequence: confirmed → in_progress → awaiting_cert → awaiting_ship → done → cancelled. | +| New method `_fp_check_advance_post_shop()` | Method | Called from step-state-change hooks (specifically from `fp.job.step.button_finish` after `super()`). If all `step_ids` are in `('done','skipped','cancelled')` AND state is `in_progress`: transitions to `awaiting_cert` (if `_resolve_required_cert_types()` non-empty) or `awaiting_ship` (if empty). Idempotent. Does NOT raise — gates moved into step.button_finish per D12. | +| Hardened `fp.job.step.button_finish` for the LAST open step | Method | When finishing a step that would leave all steps terminal, run the bake-window / qty-reconciliation / QC gates from the old `button_mark_done` BEFORE allowing the finish. On failure: raise UserError, step stays open, operator fixes + retries. Same manager-bypass context flags as today (`fp_skip_bake_gate`, `fp_skip_qty_reconcile`, `fp_skip_qc_gate`). | +| New method `_fp_check_advance_after_cert_issue()` | Method | Called from `fp.certificate.action_issue`. If every required cert for the job is `issued`: transition `awaiting_cert → awaiting_ship`. Idempotent. | +| New method `_fp_check_regress_after_cert_void()` | Method | Called from `fp.certificate.write({'state':'voided'})`. If any required cert is no longer `issued`: transition `awaiting_ship → awaiting_cert` and re-fire notification under `cert_voided_re_notify`. | +| New method `button_mark_shipped()` | Method | Manual transition `awaiting_ship → done`. Restricted via `groups=` on the form button (Manager/Owner). Reuses the existing `button_mark_done` body for side effects (delivery wiring, notifications, chatter), but **does not include the step/QC/bake/qty gates** (those already passed when we transitioned to `awaiting_cert`). | +| Repurpose existing `button_mark_done()` | Method | Becomes an internal-only method called from the auto-transitions. Visible operator action is now `button_mark_shipped`. Existing callers remain valid but are now triggered by the state machine rather than direct user clicks. | + +### `fp.certificate` model + +| Change | Type | Notes | +|---|---|---| +| Hardened `action_issue()` | Method | Adds Python-side `AccessError` if user lacks QM/Manager/Owner. Calls `job._fp_check_advance_after_cert_issue()` after successful issue. Manager bypass via context flag `fp_skip_cert_authority_gate=True` (posts chatter audit). | +| `write({'state':'voided'})` override | Method | Calls `job._fp_check_regress_after_cert_void()` after the void completes. | +| `x_fc_age_hours` | Float, non-stored, computed | Drives the Quality Dashboard age chip + overdue filter. `(now - create_date).total_seconds() / 3600`. | + +### `fp.notification.template` data + +| Change | Notes | +|---|---| +| New selection values on `trigger_event`: `('cert_awaiting_issuance', 'Cert Awaiting Issuance')`, `('cert_voided_re_notify', 'Cert Voided — Please Re-Issue')` | Loaded via `data/fp_notification_events_data.xml`. | +| Two seeded `fp.notification.template` records (one per event) | `data/fp_cert_authority_templates.xml`. Default body shown under "Notification changes" further below. Editable via UI (Plating → Configuration → Quality & Documents → Notification Templates). | +| New recipient resolver helper `_fp_resolve_cert_authority_users(job)` on `fp.notification.template` | Wraps the group-membership search (Rule 13l pattern). Dispatched when `trigger_event` is `cert_awaiting_issuance` or `cert_voided_re_notify`. | + +### `mail.activity` integration + +| Change | Notes | +|---|---| +| New `mail.activity.type` xmlid `fusion_plating_jobs.activity_type_issue_coc` | Title: "Issue CoC". Default summary template: `Issue CoC for {{ job.display_wo_name }}`. | +| `_fp_schedule_cert_activity(job)` helper on `fp.job` | Picks one QM from `_fp_resolve_cert_authority_users`, sorted by `login_date asc nulls first` — the QM who logged in least recently (likely least busy / hasn't been on the system in a while). Creates the activity. Auto-resolves on `awaiting_ship` transition. `login_date` chosen over a custom `last_activity_at` field because it's standard on `res.users` and always populated. | + +## Plant Kanban changes + +### Controller — `fusion_plating_shopfloor/controllers/plant_kanban.py` + +```python +# Line 73-75 — widen the domain +domain = [ + ('state', 'in', ('confirmed', 'in_progress', + 'awaiting_cert', 'awaiting_ship')), +] + +# Line ~165 — extend _resolve_card_area +def _resolve_card_area(job): + 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 (unchanged) +``` + +### Card-state catalog — `fusion_plating_jobs/models/fp_job.py` + +Two new values added to the `_compute_card_state` precedence chain. Inserted BEFORE the existing `done` rule (which is now unreachable from `state='done'` jobs anyway because they're filtered off the board): + +```python +# Before existing Rule 8 (done): +if job.state == 'awaiting_cert': + job.card_state = 'awaiting_cert' + continue +if job.state == 'awaiting_ship': + job.card_state = 'awaiting_ship' + continue +``` + +### Chip rendering — `_state_chip()` in plant_kanban.py + +```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'} +``` + +### Sort priority — `_SORT_PRIORITY` in plant_kanban.py + +```python +'awaiting_cert': 3.5, # right after awaiting_signoff +'awaiting_ship': 8.5, # right after running +``` + +(Floats are fine — `_sort_key` returns a tuple sorted ascending, no integer assumption.) + +### SCSS — `fusion_plating_shopfloor/static/src/scss/_plant_card.scss` + +Add two new state modifier classes following the existing pattern: + +```scss +.o_fp_plant_card.state-awaiting_cert { + background-color: var(--fp-state-awaiting-cert-bg, #fff3cd); + border-left: 4px solid var(--fp-state-awaiting-cert-border, #ff9800); +} +.o_fp_plant_card.state-awaiting_ship { + background-color: var(--fp-state-awaiting-ship-bg, #d1f1d4); + border-left: 4px solid var(--fp-state-awaiting-ship-border, #2e7d32); +} +``` + +Tokens go in `_plant_tokens.scss` first (per Rule 8), with `@if $o-webclient-color-scheme == dark` darker variants (per Rule 9). + +### KPI strip + filter chips — `fusion_plating_shopfloor/static/src/js/plant_kanban.js` + +```javascript +// Two new KPI tiles +{ 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' }, + +// Two new filter chip +{ key: 'awaiting_cert', label: _t('Awaiting CoC') }, +{ key: 'awaiting_ship', label: _t('Ready to Ship') }, +``` + +Server-side KPI compute (in `plant_kanban` endpoint): + +```python +'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'), +``` + +### Mini-timeline strip — `_compute_mini_timeline_json` in fp_job.py + +When `state='awaiting_cert'`: the `inspection` dot renders as `current` with `variant='awaiting_cert'`; all 7 earlier dots render `done`; shipping dot renders `upcoming`. Same shape when `state='awaiting_ship'` — shipping is `current` with `variant='awaiting_ship'`, inspection is `done`. Lets the QM see at a glance "this card has cleared the whole line, just waiting on paperwork/shipping." + +## Quality Dashboard changes + +### Counts endpoint — `fusion_plating_quality/controllers/fp_quality_dashboard.py` + +Extend the existing `/fp/quality/dashboard/counts` response with one block: + +```python +Cert = env['fp.certificate'] +return { + # existing blocks (holds, checks, ncrs, capas, rmas) unchanged + 'certificates': { + 'open': Cert.search_count([('state', '=', 'draft')]), + 'overdue': Cert.search_count([ + ('state', '=', 'draft'), + ('create_date', '<', d1), # >24h = overdue + ]), + }, +} +``` + +### Tab UI — `fusion_plating_quality/static/src/{js,xml,scss}/fp_quality_dashboard.*` + +Sixth tab "Certificates" with: + +- **Kanban view** grouped by `state` (Draft / Issued / Voided), Draft folded open by default. +- **Card content** (per cert): WO# + part# (large), customer name, cert-type chip (CoC / CoC+Thickness / Nadcap), age chip (`Created 4h ago`, red past 24h), status badges (🟢 Thickness PDF ready · 📋 Inspection prompts captured · ⚠ Missing spec ref). +- **Two card actions**: `Open Cert` (cert form) · `Open Job` (source job, lets QM audit inspection prompts before issuing). +- **Filters above kanban**: My Customer / Today / Overdue (>24h) / Missing Fischerscope (status='pending' from S19) / High-severity Customer. +- **Group-by option**: Customer. + +**Header KPI** card on the dashboard gains: `Certificates Awaiting Issuance: ` with overdue sub-count in red. + +**Header KPI strip** across all 6 tabs stays glanceable — six small tiles in a row. + +### Menu entry + +No new menu — the Quality Dashboard already lives at Plating → Quality → Dashboard. New tab is a sibling render inside the existing client action. + +### Deep-link from notification email + +The email body includes `{{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}`. New URL param `?tab=certificates` is parsed by the OWL action's `setup()` to focus the right tab on load. + +## ACL changes + +### `fp.certificate.action_issue` Python guard + +```python +def action_issue(self): + if not self.env.context.get('fp_skip_cert_authority_gate'): + cert_authority_gids = [ + self.env.ref('fusion_plating.group_fp_quality_manager').id, + self.env.ref('fusion_plating.group_fp_manager').id, + self.env.ref('fusion_plating.group_fp_owner').id, + ] + if not (set(self.env.user.all_group_ids.ids) + & set(cert_authority_gids)): + raise AccessError(_( + "Only Quality Managers, Managers, and Owners can issue " + "certificates. Ask your QM to review and issue this CoC." + )) + else: + self.message_post(body=Markup(_( + 'Cert authority gate bypassed by ' + '%(u)s (context flag fp_skip_cert_authority_gate).' + )) % {'u': self.env.user.name}) + # existing issue logic... +``` + +### View-level button gating + +`fp_certificate_views.xml` — Issue button on form: + +```xml +