Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
gsinghpal edf3f95854 docs(brainstorm): post-shop cert + shipping job states spec
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>
2026-05-25 09:03:31 -04:00

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:

  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

# 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.

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 state
  • fp.certificate.action_issue ACL — 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

  1. Pick a currently-done WO-30058 (the triggering job) → run migration → confirm it stays at state='done' (untouched).
  2. Find an in_progress job with all steps terminal — confirm migration script moves it to the right state.
  3. Walk a fresh SO end-to-end: confirm card visibility at each column transition.
  4. 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 extension
  • views/fp_job_views.xml — Mark Shipped button (replaces Mark Done), groups gating
  • data/fp_activity_types_data.xml — new mail.activity.type activity_type_issue_coc
  • migrations/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 computed
  • views/fp_certificate_views.xmlgroups= on Issue button
  • __manifest__.py — version bump

fusion_plating_shopfloor/

  • controllers/plant_kanban.py — domain widen, column resolution, chip rendering, KPI compute, sort priority
  • static/src/js/plant_kanban.js — KPI tile + filter chip additions
  • static/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 response
  • static/src/{js,xml,scss}/fp_quality_dashboard.* — sixth tab, header KPI, deep-link ?tab=certificates parsing
  • __manifest__.py — version bump (and add fusion_plating_certificates to depends if not already)

fusion_plating_notifications/

  • models/fp_notification_template.py — new trigger_event selection values, recipient resolver helper
  • data/fp_cert_authority_templates.xml — seeded templates for both events
  • __manifest__.py — version bump

Open questions for implementation phase

  1. Where exactly does the auto-transition fire? Most likely fp.job.step.write post-hook when state changes — 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 sets step.state.
  2. Should button_mark_shipped add 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.
  3. Tablet "Send to QM" footer hint — exact wording and link target. Minor UX; can be settled during implementation.
  4. 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_jobs 19.0.10.24.0 → 19.0.11.0.0 (state extension is a minor-version bump per existing convention)
  • fusion_plating_certificates 19.0.5.4.0 → 19.0.6.0.0
  • fusion_plating_shopfloor 19.0.34.0.0 → 19.0.35.0.0
  • fusion_plating_quality 19.0.5.0.0 → 19.0.6.0.0
  • fusion_plating_notifications minor 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.