# 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: 1. **Fix the broken cert-creation path** so jobs marked done always produce the expected draft certs. 2. **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.template` dispatch. ## 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: ```python 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'`) 1. **process_description present** — raise with hint to set coating-config / fill manually. 2. **certified_by_id present** — raise with hint to set company default. 3. **contact_partner_id present AND `email` non-empty** — raise with specific hint. 4. **qty reconciliation** — defensive; reads `x_fc_job_id` if 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.job` records where `state='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_received` - `test_mark_done_blocks_on_qty_received_mismatch` - `test_mark_done_passes_with_clean_qty_reconcile` - `test_mark_done_bypass_skips_qty_received_check` - `test_create_cert_resolves_recipe_name` (replaces "coating" wording) - `test_create_cert_handles_job_with_no_recipe` - `test_create_cert_prefills_signer_from_company` - `test_create_cert_prefills_signer_from_customer_spec` - `test_create_cert_prefills_contact_from_partner` - `test_create_cert_computes_nc_quantity` - `test_create_cert_handles_null_visual_rejects` - `test_action_issue_blocks_on_missing_process_description` - `test_action_issue_blocks_on_missing_certified_by` - `test_action_issue_blocks_on_missing_contact` - `test_action_issue_blocks_on_contact_without_email` - `test_action_issue_blocks_on_qty_mismatch` - `test_action_issue_passes_when_all_data_present` - `test_create_cert_idempotency` **Manual verification on entech (post-deploy):** 1. Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs. 2. Try `action_issue` → expect blockers for unset defaults. 3. 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`.