From 671820427a20568e52e85d5877b1a8503c541562 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 12 May 2026 12:28:52 -0400 Subject: [PATCH] spec(numbering): parent-number hierarchy design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quote→SO→WO→IN→CoC→DLV→RCV→… all share a single parent number drawn from the sale order. New abstract mixin centralises naming with atomic counter increment, compliance-grade immutability, and a hard block on direct invoice creation outside the SO workflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-12-parent-number-hierarchy-design.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 fusion_plating/docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md diff --git a/fusion_plating/docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md b/fusion_plating/docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md new file mode 100644 index 00000000..b99e5d85 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md @@ -0,0 +1,371 @@ +# 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.**