11 KiB
Certificate Creation Timing + Data Completeness Gates
Date: 2026-05-18
Status: Approved for implementation
Author: Brainstorming session (gsinghpal)
Triggering incident: WO-30040 marked done with no CoC produced — chatter showed Cert auto-create (coc) failed: name 'coating' is not defined (regression in fusion_plating_jobs/models/fp_job.py:1706 where coating was referenced but never bound).
Goal
Two things, decided as one unit of work:
- Fix the broken cert-creation path so jobs marked done always produce the expected draft certs.
- Harden the data-completeness gates so a CoC cannot be issued with missing critical information.
Out of scope
- Redesigning the cert lifecycle timing (kept at
button_mark_done()). - Wizard-based "Issue CoC" flow (Approach C, rejected).
- SO-confirm cert-stub flow (Approach B, rejected).
- Email delivery refactor — issuance still triggers existing
fp.notification.templatedispatch.
Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Cert creation stays at fp.job.button_mark_done() |
All upstream data should be settled by then; existing architecture is sound — only the bug masks that. |
| D2 | Receiving must close before job-done | qty_received blank or unreconciled blocks button_mark_done. Guarantees the cert always points to a closed receiving. |
| D3 | Strict qty accounting | qty_received ≡ qty_done + qty_scrapped + qty_visual_inspection_rejects. NC qty on cert = qty_scrapped + qty_visual_inspection_rejects. |
| D4 | Per-company default signer | New res.company.x_fc_default_coc_signer_id. Customer-spec signer_user_id wins if set. |
| D5 | Per-partner default CoC contact | New res.partner.x_fc_default_coc_contact_id. Sales sets it once per customer. |
| D6 | Mandatory fields at action_issue() |
spec_reference (existing), process_description, certified_by_id, contact_partner_id with valid email, qty reconciliation. |
| D7 | Backfill action for closed jobs missing certs | One-shot server action — walks state='done' jobs whose _resolve_required_cert_types() is non-empty and have no matching cert; calls _fp_create_certificates(). |
Architecture
┌─ JOB EXECUTION ─────────────────────────────────────────────────┐
│ Steps run → Bake → QC → Receiving closed │
│ │ │
│ ▼ │
│ button_mark_done() [HARDENED GATE] │
│ existing checks PLUS: │
│ qty_received present AND │
│ qty_received ≡ qty_done + qty_scrapped + qty_rejects │
│ │ │
│ ▼ │
│ _fp_create_certificates() (bug fixed + richer prefill) │
│ Resolved sources: │
│ process_description ← job.recipe_id.name │
│ certified_by_id ← customer_spec.signer_user_id │
│ OR company.x_fc_default_coc_signer_id│
│ contact_partner_id ← partner.x_fc_default_coc_contact_id │
│ nc_quantity ← qty_scrapped + qty_visual_rejects │
│ │ │
│ ▼ │
│ Draft cert(s) — milestone advances to "Issue Certs" │
└─────────────────────────────────────────────────────────────────┘
┌─ ISSUANCE ──────────────────────────────────────────────────────┐
│ Manager opens cert → action_issue() [HARDENED GATE] │
│ existing checks PLUS: │
│ process_description present │
│ certified_by_id present │
│ contact_partner_id present, with email │
│ qty reconciliation (belt-and-suspenders vs Gate 1) │
│ │ │
│ ▼ │
│ state → issued, PDF generated, attached │
└─────────────────────────────────────────────────────────────────┘
Schema changes (additive)
| Model | New field | Type | Notes |
|---|---|---|---|
res.company |
x_fc_default_coc_signer_id |
M2O res.users |
Default signing authority. Set once per facility. |
res.partner |
x_fc_default_coc_contact_id |
M2O res.partner (children of self) |
Sales sets per customer. |
Both are additive — no data migration needed.
Module changes
| Module | Version bump | Files |
|---|---|---|
fusion_plating |
19.0.20.1.0 → 19.0.20.2.0 | models/res_company.py, views/res_company_views.xml (or settings view) |
fusion_plating_certificates |
19.0.6.1.0 → 19.0.6.2.0 | models/res_partner.py, models/fp_certificate.py, views/res_partner_views.xml |
fusion_plating_jobs |
19.0.10.8.0 → 19.0.10.9.0 | models/fp_job.py (mark_done gate + cert prefill bug fix + backfill action) |
Gate logic — button_mark_done()
Inside the existing if not skip_qty_gate and job.qty: block, add:
if not job.qty_received:
raise UserError(_(
"Job %s cannot be marked Done — Quantity Received is blank. "
"Close the receiving record for SO %s before completing this job."
) % (job.name, job.sale_order_id.name if job.sale_order_id else '?'))
accounted_out = (job.qty_done or 0) + (job.qty_scrapped or 0) \
+ (job.qty_visual_inspection_rejects or 0)
if abs(job.qty_received - accounted_out) > 0.0001:
raise UserError(_(
"Job %s qty mismatch — received %g, but qty_done (%g) + "
"qty_scrapped (%g) + visual rejects (%g) = %g. "
"Reconcile before closing."
) % (job.name, job.qty_received, job.qty_done or 0,
job.qty_scrapped or 0, job.qty_visual_inspection_rejects or 0,
accounted_out))
Manager bypass: existing fp_skip_qty_reconcile=True context covers both.
Cert prefill table (_fp_create_certificates)
| Cert field | Source |
|---|---|
| partner_id | job.partner_id (existing) |
| sale_order_id | job.sale_order_id (existing) |
| x_fc_job_id | job.id (existing) |
| certificate_type | _resolve_required_cert_types() (existing) |
| part_number | job.part_catalog_id.part_number (existing) |
| entech_wo_number | job.name (existing) |
| po_number | job.sale_order_id.x_fc_po_number (existing) |
| customer_job_no | job.sale_order_id.x_fc_customer_job_number (existing) |
| spec_reference | from customer_spec.code [+ " Rev " + revision] (existing) |
| customer_spec_id | job.customer_spec_id (existing) |
| quantity_shipped | qty_done - qty_scrapped (existing) |
| nc_quantity | qty_scrapped + qty_visual_inspection_rejects (NEW) |
| process_description | job.recipe_id.name (NEW; was broken — coating was undefined) |
| certified_by_id | customer_spec.signer_user_id OR company.x_fc_default_coc_signer_id (NEW) |
| contact_partner_id | partner.x_fc_default_coc_contact_id (NEW) |
Gate logic — action_issue() (added in sequence before state = 'issued')
- process_description present — raise with hint to set coating-config / fill manually.
- certified_by_id present — raise with hint to set company default.
- contact_partner_id present AND
emailnon-empty — raise with specific hint. - qty reconciliation — defensive; reads
x_fc_job_idif linked.
Order: cheapest checks first; first failure wins.
Edge cases
| Case | Behavior |
|---|---|
| Job has no recipe_id | process_description = False → action_issue blocks → manager fills manually. |
| Company has no default signer | certified_by_id blank → action_issue blocks. |
| Partner has no default contact | contact_partner_id blank → action_issue blocks. |
| Contact has no email | Action_issue blocks specifically on email. |
| Customer-spec overrides company signer | customer_spec.signer_user_id wins (already used by signature unification). |
| Multi-line SO with different recipes | First line with a recipe wins for process_description; manager can override. |
Re-running _fp_create_certificates |
Idempotent by (job_id, certificate_type); NEW fields only set on initial create. |
Older jobs with NULL qty_visual_inspection_rejects |
Coerce to 0; no migration needed. |
| Receiving never existed (internal rework) | Mark_done blocks; manager bypass via fp_skip_qty_reconcile=True. |
Backwards compatibility
- WO-30040 itself (already
done, no cert) is not auto-fixed by this change. - New server action "Generate missing certs for closed jobs" walks
fp.jobrecords wherestate='done'AND_resolve_required_cert_types()is non-empty AND no matching cert exists. Surfaced in the Jobs menu so the user can run once after deploy.
Test plan
Unit tests (in fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py and new fusion_plating_certificates/tests/test_action_issue_gates.py):
test_mark_done_blocks_on_blank_qty_receivedtest_mark_done_blocks_on_qty_received_mismatchtest_mark_done_passes_with_clean_qty_reconciletest_mark_done_bypass_skips_qty_received_checktest_create_cert_resolves_recipe_name(replaces "coating" wording)test_create_cert_handles_job_with_no_recipetest_create_cert_prefills_signer_from_companytest_create_cert_prefills_signer_from_customer_spectest_create_cert_prefills_contact_from_partnertest_create_cert_computes_nc_quantitytest_create_cert_handles_null_visual_rejectstest_action_issue_blocks_on_missing_process_descriptiontest_action_issue_blocks_on_missing_certified_bytest_action_issue_blocks_on_missing_contacttest_action_issue_blocks_on_contact_without_emailtest_action_issue_blocks_on_qty_mismatchtest_action_issue_passes_when_all_data_presenttest_create_cert_idempotency
Manual verification on entech (post-deploy):
- Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs.
- Try
action_issue→ expect blockers for unset defaults. - Configure defaults; retry → cert issues, PDF renders, attaches.
Deployment
- Push to
K:/Github/Odoo-Modules/fusion_plating/(git path). - Mirror to docker mount as needed.
- Update on entech LXC 111 via the deploy commands in
project_entech_session_handoff.md. - Module install order:
fusion_plating→fusion_plating_certificates→fusion_plating_jobs.