spec(numbering): parent-number hierarchy design

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 12:28:52 -04:00
parent b07f771d98
commit 671820427a

View File

@@ -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:
```
<PREFIX>-<parent> ← first / only child (bare)
<PREFIX>-<parent>-NN ← 2nd through 99th (zero-padded 2-digit)
<PREFIX>-<parent>-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-<parent>`.
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 <counter_field> 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 <counter_field> = <new index> 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-<parent>` or `IN-<parent>-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 `<name>` 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 `<PREFIX>-30000`, `<PREFIX>-30000-02`, ..., which sort alphabetically as expected.
## 9. Sequence Definitions
New sequences (XML data):
```xml
<record id="seq_fp_quote_number" model="ir.sequence">
<field name="name">Fusion Plating: Quote Number</field>
<field name="code">fp.quote.number</field>
<field name="prefix">Q%(year)s%(month)s-</field>
<field name="padding">0</field> <!-- non-padding sequential -->
<field name="use_date_range" eval="False"/> <!-- counter never resets -->
<field name="number_next_actual">200</field> <!-- start from current quote count -->
</record>
<record id="seq_fp_parent_number" model="ir.sequence">
<field name="name">Fusion Plating: Parent Number</field>
<field name="code">fp.parent.number</field>
<field name="prefix"/> <!-- no prefix, just the integer -->
<field name="padding">0</field>
<field name="number_next_actual">30000</field>
</record>
```
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.**