Trigger: WO-30058 (SO-30058) finished all recipe steps on entech and disappeared from the Shop Floor kanban with a draft CoC and no QM notification. Operators reported jobs feeling lost; risk that a job could leave the building without paperwork. Design (Approach A, approved): - Two new fp.job.state values between in_progress and done: awaiting_cert + awaiting_ship - Auto-advance on last step finish; auto-advance on cert issue - Plant kanban widens domain, renders the two states in the existing Final inspection / Shipping columns - 6th tab 'Certificates' on Quality Dashboard with kanban + filters - ACL gate on fp.certificate.action_issue restricted to Manager / QM / Owner (transitive via all_group_ids) - Email + mail.activity notification to QM authority group - Migration script backfills mid-flight jobs Shipping label printing, BoL, carrier dispatch are explicitly out of scope; awaiting_ship is a parking column with a manual Mark Shipped button. Self-review pass found and fixed: - round-robin field ambiguity (last_activity_at vs login_date) - unstated behavior for button_mark_done gates (now in step.finish) - placeholder version inlined (19.0.11.0.0) - dead reference replaced with inline body Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
35 KiB
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:
- 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.
- 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.
- Model the actual lifecycle in
fp.job.stateso 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_shipstate is a parking column so jobs are visible to the shipping crew.awaiting_ship → doneis a manual button click. (User: "lets first finish this certification step then we will look into shipping".) - Auto-transition from
awaiting_ship → doneondelivery.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
# 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):
# 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
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
'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:
.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
// 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):
'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:
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: <count> 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
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 <b>bypassed</b> by '
'<b>%(u)s</b> (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:
<button name="action_issue" string="Issue" type="object"
invisible="state != 'draft'"
groups="fusion_plating.group_fp_quality_manager,
fusion_plating.group_fp_manager,
fusion_plating.group_fp_owner"/>
Tablet impact
S18-era cert flow ran any operator session. Post-change: remove the Issue affordance from operator-facing tablet surfaces. The cert form is still openable (read-only for Technicians); the action button is hidden. Surface a "Send to QM" hint on the job workspace footer when state is awaiting_cert, but the actual notification fires automatically — no operator click required.
Notification changes
New trigger events
Add to fp.notification.template.trigger_event selection:
('cert_awaiting_issuance', _('Cert Awaiting Issuance')),
('cert_voided_re_notify', _('Cert Voided — Please Re-Issue')),
Seeded template — data/fp_cert_authority_templates.xml
Single template per event (subject, body, recipient_resolver = cert_authority_group_members). Default body (per the Architecture diagram above and the section that follows on the recipient resolver):
Subject: 🏷️ Job {{ object.display_wo_name }} ready for CoC issuance
Hi {{ recipient.name|first_name }},
Job {{ object.display_wo_name }} ({{ object.partner_id.name }})
has finished the shop floor and is awaiting CoC issuance.
Part: {{ object.part_catalog_id.part_number }}
Quantity: {{ object.qty_done }}
Recipe: {{ object.recipe_id.name }}
Review the inspection prompts captured by the operator on the Final
Inspection step, then issue the CoC from the Quality Dashboard:
→ {{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}
Or open the job directly:
→ {{ object.x_fc_record_url }}
Marked noupdate="1" so admin edits in the UI survive -u (Rule 22).
Recipient resolver — _fp_resolve_cert_authority_users()
@api.model
def _fp_resolve_cert_authority_users(self, job=None):
"""Return active, non-share users with QM | Manager | Owner role
(transitive via all_group_ids). See Rule 13l for the rationale —
direct user_ids on group records does NOT include implied
memberships."""
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,
]
return self.env['res.users'].sudo().search([
('all_group_ids', 'in', gids),
('share', '=', False),
('active', '=', True),
])
Throttling / dedupe
fp.notification.log dedupe key is (template_id, source_record_id, event). The two events have distinct keys, so a cert_voided_re_notify after cert_awaiting_issuance re-fires (correct — different signal). Repeated cert_awaiting_issuance for the same job is suppressed (correct — already on the QM's radar).
mail.activity belt + suspenders
After firing the notification, schedule one activity per job (not per QM — round-robin assignment to a single QM, who can re-assign if needed). Auto-resolves when state transitions to awaiting_ship.
def _fp_schedule_cert_activity(self):
self.ensure_one()
activity_type = self.env.ref(
'fusion_plating_jobs.activity_type_issue_coc',
raise_if_not_found=False,
)
if not activity_type:
return
qm = self.env['fp.notification.template']._fp_resolve_cert_authority_users(self)
if not qm:
return
# Round-robin: pick the QM who logged in least recently (likely
# least busy). NULL login_date sorts first.
qm = qm.sorted(lambda u: u.login_date or fields.Datetime.from_string('1970-01-01'))[:1]
self.activity_schedule(
activity_type_id=activity_type.id,
user_id=qm.id,
summary=_('Issue CoC for %s') % (self.display_wo_name or self.name),
note=_('Job has finished the shop floor. Review the inspection '
'prompts captured on the final step, then issue the CoC.'),
)
Auto-resolve on awaiting_ship transition:
def _fp_resolve_cert_activities(self):
self.ensure_one()
activity_type = self.env.ref(
'fusion_plating_jobs.activity_type_issue_coc',
raise_if_not_found=False,
)
if not activity_type:
return
self.activity_ids.filtered(
lambda a: a.activity_type_id == activity_type
).action_feedback(feedback=_('Cert issued — auto-resolved.'))
Migration plan
Pre-migration state assessment
-- Count jobs currently affected
SELECT state, count(*)
FROM fp_job
WHERE state IN ('confirmed', 'in_progress', 'done')
GROUP BY state;
-- Jobs in-progress with all steps terminal (will migrate to awaiting_cert or awaiting_ship)
SELECT j.id, j.name, j.state,
count(s.id) AS total_steps,
count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled')) AS terminal_steps
FROM fp_job j
LEFT JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id, j.name, j.state
HAVING count(s.id) > 0
AND count(s.id) = count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled'));
-- Done jobs with draft certs (would need backfill in the new world, but we leave them alone)
SELECT j.id, j.name, count(c.id) AS draft_certs
FROM fp_job j
JOIN fp_certificate c ON c.x_fc_job_id = j.id AND c.state = 'draft'
WHERE j.state = 'done'
GROUP BY j.id, j.name;
Migration script — fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py
def migrate(cr, version):
"""Backfill new states for jobs caught mid-transition by the upgrade.
Rules:
- in_progress + all steps terminal + draft cert exists → awaiting_cert
- in_progress + all steps terminal + no cert required → awaiting_ship
- done jobs LEFT ALONE — they're historical (already shipped)
"""
cr.execute("""
UPDATE fp_job
SET state = 'awaiting_cert'
WHERE id IN (
SELECT j.id
FROM fp_job j
JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id
HAVING count(*) FILTER (
WHERE s.state NOT IN ('done','skipped','cancelled')
) = 0
)
AND EXISTS (
SELECT 1 FROM fp_certificate c
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
);
""")
cr.execute("""
UPDATE fp_job
SET state = 'awaiting_ship'
WHERE id IN (
SELECT j.id
FROM fp_job j
JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id
HAVING count(*) FILTER (
WHERE s.state NOT IN ('done','skipped','cancelled')
) = 0
)
AND NOT EXISTS (
SELECT 1 FROM fp_certificate c
WHERE c.x_fc_job_id = fp_job.id
AND c.state IN ('draft', 'issued')
);
""")
Idempotent: re-running on a fresh upgrade is a no-op because no in_progress job will match the all-terminal predicate after the first run.
Card_state recompute
After the migration script runs, force a recompute of fp.job.card_state (stored compute) so the kanban renders correctly:
# In post-migrate.py, after the state UPDATEs:
env = api.Environment(cr, SUPERUSER_ID, {})
affected = env['fp.job'].search([
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
])
affected.invalidate_recordset(['card_state'])
affected._compute_card_state()
Edge cases + defensive design
| Case | Behavior |
|---|---|
| Job with no recipe steps at all | _fp_check_advance_post_shop returns early (nothing to check). State stays at whatever it was — operator can still manually move it via the existing milestone-advance button. |
| Recipe doesn't have a Final Inspection step | Card still lands in Final Inspection column (state drives column, not recipe shape). The recipe author probably forgot to add the step — Quality Dashboard surface catches it. |
| Customer not flagged for any cert | _resolve_required_cert_types() returns empty set → state transitions in_progress → awaiting_ship directly. Card visible in Shipping column. No notification fires (no QM action needed). |
QM voids a cert AFTER awaiting_ship |
_fp_check_regress_after_cert_void() flips state back to awaiting_cert, re-fires cert_voided_re_notify (different dedupe key from initial notification, so not suppressed). Card visibly moves left from Shipping back to Final Inspection. |
Operator marks a step as cancelled mid-flow that bricks the all-terminal check |
cancelled IS terminal per the existing logic — so cancelling the last open step DOES trigger the transition. Operator-error path: if a manager opens a previously-terminal step (e.g. _fp_reopen_step) the state should regress to in_progress. Add inverse trigger on step reopen. |
Cron-triggered cert issuance (e.g. some future auto-issue) with no env.user |
The Python guard hits self.env.user which would be the cron user. Need cron-safe bypass: use fp_skip_cert_authority_gate=True in the cron's with_context(...) call. Documented but no cron exists today. |
Migration: existing state='done' jobs that haven't shipped |
Left alone — historically completed jobs are out of scope. Backfill action (action_backfill_missing_certs in fp_job.py) already exists for cert gaps; that path is unchanged. |
| Multiple QMs assigned a single activity | Round-robin picks ONE QM (oldest login_date). They can reassign via the activity widget if needed. Activity auto-resolves on awaiting_ship regardless of which QM acted. |
Testing strategy
Smoke test (entech-style, scriptable)
# scripts/bt_post_shop_states.py
# 1. Create SO + job with cert-requiring customer
# 2. Walk every step to terminal → assert state='awaiting_cert'
# 3. Assert card appears in plant_kanban under 'inspection' column
# 4. Assert email + activity scheduled on a QM
# 5. As a Technician, call cert.action_issue() → assert AccessError
# 6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
# 7. Assert card moves to 'shipping' column, activity auto-resolves
# 8. Void the cert → assert state back to 'awaiting_cert', activity re-scheduled
# 9. Re-issue → 'awaiting_ship' again
# 10. Click button_mark_shipped (as Manager) → state='done', card off board
Unit-level
fp.job._fp_check_advance_post_shop— exhaustive matrix of (state, all_terminal, certs_required) → expected new statefp.certificate.action_issueACL — separate tests per role (Tech/Mgr/QM/Owner/Sales Rep)_fp_resolve_cert_authority_users— entech-realistic group setup, confirm Owners are returned via implication
Manual QA on entech
- Pick a currently-done WO-30058 (the triggering job) → run migration → confirm it stays at
state='done'(untouched). - Find an
in_progressjob with all steps terminal — confirm migration script moves it to the right state. - Walk a fresh SO end-to-end: confirm card visibility at each column transition.
- Try issuing a cert as a Technician via the JS-rpc console → confirm AccessError.
Files touched
fusion_plating_jobs/
models/fp_job.py— state extension, new methods, repurpose button_mark_done, new button_mark_shipped, hooks for cert state changes, mini-timeline update, card-state extensionviews/fp_job_views.xml— Mark Shipped button (replaces Mark Done), groups gatingdata/fp_activity_types_data.xml— new mail.activity.typeactivity_type_issue_cocmigrations/19.0.<next>/post-migrate.py— state backfill__manifest__.py— version bump, data file additions
fusion_plating_certificates/
models/fp_certificate.py— Python ACL guard in action_issue, write override for state=voided, x_fc_age_hours computedviews/fp_certificate_views.xml—groups=on Issue button__manifest__.py— version bump
fusion_plating_shopfloor/
controllers/plant_kanban.py— domain widen, column resolution, chip rendering, KPI compute, sort prioritystatic/src/js/plant_kanban.js— KPI tile + filter chip additionsstatic/src/scss/_plant_card.scss+_plant_tokens.scss— new state modifier classes (light + dark via$o-webclient-color-scheme)__manifest__.py— version bump
fusion_plating_quality/
controllers/fp_quality_dashboard.py— certificates block in counts responsestatic/src/{js,xml,scss}/fp_quality_dashboard.*— sixth tab, header KPI, deep-link?tab=certificatesparsing__manifest__.py— version bump (and addfusion_plating_certificatesto depends if not already)
fusion_plating_notifications/
models/fp_notification_template.py— new trigger_event selection values, recipient resolver helperdata/fp_cert_authority_templates.xml— seeded templates for both events__manifest__.py— version bump
Open questions for implementation phase
- Where exactly does the auto-transition fire? Most likely
fp.job.step.writepost-hook whenstatechanges — needs centralization so all step-completion paths (button_finish, action_skip, action_cancel, etc.) trigger consistently. Implementation plan should validate by grepping for every site that setsstep.state. - Should
button_mark_shippedadd ANY gates (e.g. delivery exists / draft cert isn't lingering)? Default answer: no — the state machine has already validated correctness; the button is just "yes, shipped". But worth re-confirming. - Tablet "Send to QM" footer hint — exact wording and link target. Minor UX; can be settled during implementation.
- Mini-timeline dot for
done(state at end-of-lifecycle) — currently the timeline is always 9 dots regardless of state. After shipping the card is off the board, so this only matters for historical viewers. Probably no change needed; flag for implementation review.
Status & deployment notes
Target version bumps (suggestion, finalize at implementation time):
fusion_plating_jobs19.0.10.24.0 → 19.0.11.0.0 (state extension is a minor-version bump per existing convention)fusion_plating_certificates19.0.5.4.0 → 19.0.6.0.0fusion_plating_shopfloor19.0.34.0.0 → 19.0.35.0.0fusion_plating_quality19.0.5.0.0 → 19.0.6.0.0fusion_plating_notificationsminor bump
Deploy order: notifications → jobs (post-migrate runs here) → certificates → shopfloor → quality. Each gets its own -u step to keep blast radius small per CLAUDE.md → Sub 12 build order rule 11.