# Parent Number Hierarchy — Design **Date:** 2026-05-12 **Status:** Draft — pending user review **Author:** Brainstormed with Gurpreet **Scope:** Replace divergent sequences (`S00xxx` / `WH/JOB/01xxx` / `INV/2026/xxxx` / `CERT-` / `DLV/` / `RCV-` / etc.) with a single shared parent-number scheme tied to the sale order. Every document that 1:1 links to an SO derives its name from the SO's parent number. --- ## 1. Goals 1. **One source of truth.** When anyone sees a number, they immediately know which SO it belongs to. No mental lookup needed. 2. **Compliance-grade traceability.** Numbers are immutable post-issuance. Cancellation leaves gaps; gaps are part of the audit trail. Hard-deletion is blocked on every customer-shared and compliance-relevant model. 3. **Forward-only.** Existing records keep their current names. New records start fresh from `30000`. 4. **Block off-flow invoice creation.** Invoices may only be created via the sale-order workflow — no direct creation, no group-based bypass. ## 2. Non-Goals - Renumbering or migrating existing records (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, etc.). They keep their existing names until they close out naturally. - Touching docs that physically span multiple SOs: **batches** (rack/barrel — a single rack can hold parts from three different customers), **bake windows** (per-batch, same issue), **move log** (per-event audit row, too granular), **equipment / maintenance / calibration / NADCAP audits** (equipment-bound). These keep their existing sequences. - Multi-company numbering segregation. (One company in scope.) ## 3. Naming Rules ### 3.1 Quote name While the `sale.order` is in `state == 'draft'` (a quotation), the name uses a non-resetting per-month counter: ``` Q + YYYY + MM + - + N e.g. Q202605-200, Q202605-201, Q202606-202 ``` The `N` counter never resets — only the year/month prefix rolls. New `ir.sequence` `fp.quote.number` handles this with prefix `Q%(year)s%(month)s-` and `padding=0`. ### 3.2 Parent number When the SO is confirmed (`action_confirm`), a new integer is drawn from `ir.sequence` `fp.parent.number` (starts at `30000`, increments by 1, never resets). Stored on the SO as `x_fc_parent_number`. The pre-confirm quote name is preserved in `x_fc_quote_ref`. ### 3.3 Child names Every child document linked to an SO is named as: ``` - ← first / only child (bare) --NN ← 2nd through 99th (zero-padded 2-digit) --NNN ← 100th and beyond (unpadded — practically unreachable) ``` | Model | Prefix | Example | |------------------------------------|---------|--------------------------| | `sale.order` (confirmed) | `SO` | `SO-30000` | | `fp.job` | `WO` | `WO-30000`, `WO-30000-02`| | `account.move` (customer invoice) | `IN` | `IN-30000`, `IN-30000-02`| | `account.move` (customer refund) | `CN` | `CN-30000-02` | | `fp.certificate` | `CoC` | `CoC-30000` | | `fusion.plating.delivery` | `DLV` | `DLV-30000` | | `fp.receiving` | `RCV` | `RCV-30000` | | `fusion.plating.pickup.request` | `PU` | `PU-30000` | | `fusion.plating.ncr` | `NCR` | `NCR-30000-02` | | `fusion.plating.capa` | `CAPA` | `CAPA-30000` | | `fusion.plating.quality.hold` | `HOLD` | `HOLD-30000` | | `fusion.plating.rma` | `RMA` | `RMA-30000` | ### 3.4 WO suffix at SO confirm — special case WOs are unique in that **the full set is materialized at SO-confirm time** (one WO per recipe group). All other docs are created on demand later. So WO suffixing at confirm: - 1 recipe group → 1 WO named bare (`WO-30000`). - N recipe groups → N WOs named `WO-30000-01`, `WO-30000-02`, ..., `WO-30000-N` (zero-padded). Suffix matches creation order (group sorted by `min(line.sequence)`). If a user **later** manually adds an extra WO to the SO: - If the original was bare (1 group originally) → new WO is `WO-30000-02`. Bare one stays bare. (Bare implicitly carries index `1`.) - If the originals were suffixed → new WO continues the count (`WO-30000-N+1`). This is the only model where the bare-vs-suffix decision happens at create-time-of-the-set rather than create-time-of-the-individual. All other models follow the simple rule: first = bare, subsequent = suffixed. ### 3.5 Existing records **Untouched.** Records with old-format names (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, `CERT-00001`, `DLV/2026/0001`, `RCV-00001`, …) keep their existing names forever. They age out as jobs close. Sequences are reset to start producing the new format from `30000`. ## 4. Data Model ### 4.1 New fields on `sale.order` | Field | Type | Notes | |------------------------|---------|-----------------------------------------------------------------------| | `x_fc_quote_ref` | Char | The original quote-stage name (`Q202605-200`). Preserved after confirm. | | `x_fc_parent_number` | Integer | Assigned on `action_confirm`. Drives child naming. Indexed. | | `x_fc_wo_count` | Integer | Cached number of WOs issued. Monotonic. | | `x_fc_invoice_count` | Integer | Cached. Monotonic. | | `x_fc_cn_count` | Integer | Customer credit notes. Monotonic. | | `x_fc_cert_count` | Integer | CoCs issued. Monotonic. | | `x_fc_delivery_count` | Integer | Deliveries. Monotonic. | | `x_fc_receiving_count` | Integer | Receivings. Monotonic. | | `x_fc_pickup_count` | Integer | Pickup requests. Monotonic. | | `x_fc_ncr_count` | Integer | NCRs raised against this SO. Monotonic. | | `x_fc_capa_count` | Integer | CAPAs. Monotonic. | | `x_fc_hold_count` | Integer | Quality holds. Monotonic. | | `x_fc_rma_count` | Integer | RMAs. Monotonic. | Counters are **monotonic and never decrement**, even on cancellation/unlink (which itself is blocked — see §6). ### 4.2 New field on every child model | Field | Type | Notes | |------------------|---------|--------------------------------------------------------------------| | `x_fc_doc_index` | Integer | The index this child was assigned (1, 2, 3, …). `readonly=True` after create. Indexed jointly with the link to SO. | ### 4.3 New abstract model: `fp.parent.numbered.mixin` ```python class FpParentNumberedMixin(models.AbstractModel): _name = 'fp.parent.numbered.mixin' _description = 'Fusion Plating — Parent-Number-Derived Naming' x_fc_doc_index = fields.Integer( string='Parent Doc Index', readonly=True, copy=False, index=True, help='Sequential index within this parent SO (1 = first child).', ) # ---- Hooks subclasses override -------------------------------- def _fp_parent_sale_order(self): """Return the linked sale.order record or self.env['sale.order'].""" raise NotImplementedError def _fp_name_prefix(self): """Return the 2-4 letter prefix for this model (e.g. 'WO', 'IN').""" raise NotImplementedError def _fp_parent_counter_field(self): """Return the field name on sale.order that counts THIS model's children.""" raise NotImplementedError # ---- Core (sealed) -------------------------------------------- def _fp_assign_parent_name(self): """Atomically: lock the parent SO, read+bump the counter, assign x_fc_doc_index and name. Used by subclass create() hooks.""" # implementation in §5.3 ``` Subclasses register by: ```python class FpJob(models.Model): _name = 'fp.job' _inherit = ['fp.job', 'fp.parent.numbered.mixin'] # multi-inherit pattern def _fp_parent_sale_order(self): return self.sale_order_id def _fp_name_prefix(self): return 'WO' def _fp_parent_counter_field(self): return 'x_fc_wo_count' ``` ## 5. Behaviour ### 5.1 Quote creation (`sale.order.create`) Override existing `create()` so that when a new sale.order is created and no `name` is provided (or `name == 'New'`), it pulls from the `fp.quote.number` sequence rather than Odoo's default. The resulting name (`Q202605-200`) is also stored in `x_fc_quote_ref` so it's preserved verbatim after confirm. ### 5.2 SO confirm (`sale.order.action_confirm`) In the existing confirm flow, AFTER any existing checks but BEFORE `_fp_native_jobs_for_so()` (the WO creation): 1. If `x_fc_parent_number` is unset: 1. Draw next from `fp.parent.number` (starts at 30000). 2. Write `x_fc_parent_number` and rename `name` from `Q...` to `SO-`. 3. Post chatter: *"Confirmed quote Q202605-200 as SO-30000."* 2. Proceed with WO creation (§5.4 below). ### 5.3 Atomic counter increment (mixin core) `_fp_assign_parent_name()` does, in order, in a single transaction: 1. `cr.execute("SELECT FROM sale_order WHERE id = %s FOR UPDATE", [so.id])` — acquires a row-level lock on the parent SO until commit. 2. Reads the current count. 3. Computes the new index `= current + 1`. 4. `UPDATE sale_order SET = WHERE id = %s`. 5. Sets `self.x_fc_doc_index = new_index` and `self.name = self._fp_compose_name(new_index)`. 6. Posts chatter on the parent SO: *"Issued WO-30000-02 to fp.job #1234."* Composition rule: ```python def _fp_compose_name(self, index): so = self._fp_parent_sale_order() parent = so.x_fc_parent_number prefix = self._fp_name_prefix() if index <= 1: return f'{prefix}-{parent}' if index <= 99: return f'{prefix}-{parent}-{index:02d}' return f'{prefix}-{parent}-{index}' ``` ### 5.4 WO creation at SO confirm — special bulk path `_fp_native_jobs_for_so()` (in `fusion_plating_jobs/models/sale_order.py`) is rewritten to: 1. Group lines by **resolved recipe id** (the 4-tier priority resolution we just shipped — `line.x_fc_process_variant_id` → `part.default_process_id` → `coating.recipe_id` → `part.recipe_id`). `x_fc_wo_group_tag` is dropped as an override mechanism (recipe-driven grouping replaces it). 2. Count the resulting groups. 3. If 1 group → create 1 job with `vals['name'] = f"WO-{parent}"` and `x_fc_doc_index = 1`. Bump SO's `x_fc_wo_count` to 1. 4. If N > 1 groups → create N jobs ordered by `min(line.sequence)`. For each, `vals['name'] = f"WO-{parent}-{i:02d}"` and `x_fc_doc_index = i`. Bump `x_fc_wo_count` to N. 5. All assignments happen inside a single `for_update` lock on the SO. If a user later manually creates an extra `fp.job` for this SO (via the form, not the SO-confirm flow), the mixin's standard path runs: lock SO → bump `x_fc_wo_count` → assign next index → compose name. ### 5.5 Invoice creation flow When `sale.order._create_invoices()` runs (deposit, progress, partial, or final invoicing): 1. The standard Odoo flow proceeds as-is for line aggregation / tax / journal selection. 2. Before `account.move.create()` is called, the SO's `_create_invoices` override sets `self = self.with_context(fp_from_so_invoice=True)`. 3. Our `account.move.create()` override: - For `move_type in ('out_invoice', 'out_refund')`: - If `not self.env.context.get('fp_from_so_invoice')` AND `not vals.get('invoice_origin')` matching an SO name → **raise `UserError`** ("Customer invoices must be created from a Sale Order. Open the SO and use Create Invoice."). No group bypass; applies to admins. - Else proceed. - Post-create, immediately invoke `_fp_assign_parent_name()` (mixin pulls SO via `invoice_origin` lookup) — which assigns `x_fc_doc_index` and overrides `name` from the journal-default `INV/2026/xxxx` to `IN-` or `IN--NN`. Credit notes (`out_refund`) use prefix `CN` and counter `x_fc_cn_count`. ### 5.6 Other child models — uniform path For `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`: - Each model's existing `create()` override (which currently pulls from its own `ir.sequence`) is rewritten to resolve the parent SO and call `_fp_assign_parent_name()`. - If the record has **no** linked SO (e.g. a standalone NCR raised from a calibration finding, an RMA from a generic customer complaint with no SO), the mixin falls back to the model's old sequence (e.g. `NCR-2026-NNN`). - The old sequences stay in place as the standalone fallback. They're not removed. ### 5.7 Direct-creation block on invoices Implementation in §5.5. Concrete error message: > *"Customer invoices and credit notes must be created from a Sale Order. Open the originating SO and use the Create Invoice / Add Credit Note action. This rule applies to all users including administrators — it is enforced to keep the parent-number audit trail intact."* The check is in `account.move._create_invoice_check_so()` (new helper), called from the create override. The helper: 1. Reads `move_type`. 2. If not customer-facing (`out_invoice`/`out_refund`) → pass. 3. Else, look for `fp_from_so_invoice=True` in context OR `invoice_origin` resolving to an existing `sale.order`. 4. If neither → raise. ## 6. Immutability and Deletion ### 6.1 `name` and `x_fc_doc_index` are immutable The mixin sets `name` and `x_fc_doc_index` with `readonly=True`. Additionally, a `write()` override raises `UserError` if either field is in the values dict and the record already has a non-empty name. No code path can rename a record post-creation. ### 6.2 `unlink()` blocked on compliance models For: `sale.order`, `account.move` (customer invoices and credit notes), `fp.certificate`, `fp.job`, `fusion.plating.delivery`, `fp.receiving`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`. `unlink()` raises `UserError` for **every** user (no group bypass) when the record has a name AND its state is not `draft`. The error message: > *"Document `` cannot be deleted — it is part of the compliance audit trail. Cancel it instead (state machine handles cancellation). This rule applies to all users including administrators."* Draft records (no name assigned yet, never issued) can be deleted normally — they're not yet part of the audit trail. ### 6.3 Cancellation leaves gaps When `IN-30000-02` is cancelled, the counter `x_fc_invoice_count` is NOT decremented. The next invoice for SO-30000 is `IN-30000-03`. The audit chatter and the `x_fc_doc_index` field both record `IN-30000-02` as issued + cancelled. ## 7. Spanning Documents — Exception List The following keep their existing per-model sequences (NOT touched by this design): | Model | Reason | |--------------------------------|-----------------------------------------------------------------------------| | `fusion.plating.batch` | A rack/barrel can hold parts from multiple SOs simultaneously. | | `fusion.plating.bake.window` | Per-batch; same reasoning. | | `fp.job.step.move` | Per-event audit row; too granular and per-step, not per-SO. | | `maintenance.equipment` / plans| Equipment-bound, not order-bound. | | Compliance docs (Nadcap, ITP, CFT, RISK, SPILL, INC, etc.) | Audit / event-driven, not SO-driven. | This list is exhaustive — every other linked document gets the parent-number treatment. ## 8. Reports + Views ### 8.1 Reports to verify show parent-derived names All these read `record.name` directly, so they "just work" once the data flow is right. Verification checklist: - [ ] Quote PDF (standard Odoo sale report — uses `name`) - [ ] Sale Order confirmation PDF - [ ] Invoice PDF (standard Odoo) - [ ] WO Detail PDF — **bug to fix**: current `short_wo` derivation uses `(job.name or '').split('/')[-1]` which assumed `WH/JOB/` prefix. Update to either strip the new `WO-` prefix or simply show full `job.name`. - [ ] CoC EN / FR PDFs - [ ] Chronological CoC PDF - [ ] Traveller PDF - [ ] Delivery / Packing Slip / BoL PDFs - [ ] Job Sticker - [ ] Rack Travel Ticket - [ ] WO Margin report ### 8.2 Form views - `sale.order` form: after confirm, show both `x_fc_quote_ref` (small grey "Originally quoted as Q202605-200") and `name` (big SO-30000 heading). - All child forms: show `name` (no change to layout). - All search views referencing `WH/JOB/` or `INV/` prefixes in `decoration-info` style hooks should be neutral — they don't typically depend on the prefix. ### 8.3 List/kanban views No structural changes. The sort order by `name` works fine since all new names follow `-30000`, `-30000-02`, ..., which sort alphabetically as expected. ## 9. Sequence Definitions New sequences (XML data): ```xml Fusion Plating: Quote Number fp.quote.number Q%(year)s%(month)s- 0 200 Fusion Plating: Parent Number fp.parent.number 0 30000 ``` Existing sequences (`fp.job`, `account.move` journal, `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fp.rma`) stay defined and are used as fallbacks for standalone-no-SO cases (§5.6). ## 10. Migration - New sequences added with `number_next_actual` at the target starting values (30000 for parent, 200 for quote). - No data backfill on existing records. - Module upgrade rolls out: - Mixin abstract model - Field additions on `sale.order` and on each child - Create/write/unlink overrides - View tweaks - Rollback path: re-installing the prior version restores the old `create()` flows. The new fields on existing records would become unused but harmless. ## 11. Open Items / Edge Cases 1. **Pickup request before SO exists.** Pickup requests can be raised before an SO is confirmed (or even created). The mixin's standalone fallback covers this. If a pickup is later linked to a confirmed SO, the name is NOT retroactively changed (immutability rule). A separate `x_fc_so_id` link records the relationship; the original name stays. 2. **Quote sequence migration.** The `number_next_actual=200` is illustrative. Confirmed value from the user before the spec is implemented (he stated "Q202605-200" as the format with `200` as the example counter, so we start there or at any agreed value). 3. **Reports not updating display label.** The WO Detail's `short_wo` derivation is the one known concrete report-side break. The rest read `name` raw and don't need template changes. 4. **Manual job creation outside the SO flow.** Users may manually create an `fp.job` from the form (rare). The mixin's standard path handles this: requires a linked SO, locks it, bumps counter, assigns name. If no SO is linked, raise UserError. ## 12. Implementation Order (high-level) Detailed step-by-step plan to be produced by `writing-plans` skill. High-level sequence: 1. Add `fp.parent.numbered.mixin` abstract model + sequence data. 2. Add SO fields (`x_fc_quote_ref`, `x_fc_parent_number`, counters) and `x_fc_doc_index` on each child. 3. Quote/SO rename in `sale.order.create` and `action_confirm`. 4. Block direct invoice creation (override on `account.move.create`). 5. Wire each child model into the mixin: `fp.job` first (most critical), then `account.move` (invoice/credit note), then `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, then quality models (NCR, CAPA, Hold, RMA). 6. Add `unlink()` and `write()` overrides for immutability. 7. WO recipe-group rewrite of `_fp_native_jobs_for_so` (replaces existing `x_fc_wo_group_tag` grouping). 8. View tweaks: SO form quote ref display. 9. Fix WO Detail report's `short_wo` derivation. 10. Full audit walkthrough on a fresh DB: create quote → confirm → ship → invoice → CoC → verify every doc shows parent-derived name. --- **End of design.**