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>
21 KiB
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
- One source of truth. When anyone sees a number, they immediately know which SO it belongs to. No mental lookup needed.
- 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.
- Forward-only. Existing records keep their current names. New records start fresh from
30000. - 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 bymin(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 index1.) - 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
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:
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):
- If
x_fc_parent_numberis unset:- Draw next from
fp.parent.number(starts at 30000). - Write
x_fc_parent_numberand renamenamefromQ...toSO-<parent>. - Post chatter: "Confirmed quote Q202605-200 as SO-30000."
- Draw next from
- 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:
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.- Reads the current count.
- Computes the new index
= current + 1. UPDATE sale_order SET <counter_field> = <new index> WHERE id = %s.- Sets
self.x_fc_doc_index = new_indexandself.name = self._fp_compose_name(new_index). - Posts chatter on the parent SO: "Issued WO-30000-02 to fp.job #1234."
Composition rule:
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:
- 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_tagis dropped as an override mechanism (recipe-driven grouping replaces it). - Count the resulting groups.
- If 1 group → create 1 job with
vals['name'] = f"WO-{parent}"andx_fc_doc_index = 1. Bump SO'sx_fc_wo_countto 1. - If N > 1 groups → create N jobs ordered by
min(line.sequence). For each,vals['name'] = f"WO-{parent}-{i:02d}"andx_fc_doc_index = i. Bumpx_fc_wo_countto N. - All assignments happen inside a single
for_updatelock 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):
- The standard Odoo flow proceeds as-is for line aggregation / tax / journal selection.
- Before
account.move.create()is called, the SO's_create_invoicesoverride setsself = self.with_context(fp_from_so_invoice=True). - Our
account.move.create()override:- For
move_type in ('out_invoice', 'out_refund'):- If
not self.env.context.get('fp_from_so_invoice')ANDnot vals.get('invoice_origin')matching an SO name → raiseUserError("Customer invoices must be created from a Sale Order. Open the SO and use Create Invoice."). No group bypass; applies to admins. - Else proceed.
- If
- Post-create, immediately invoke
_fp_assign_parent_name()(mixin pulls SO viainvoice_originlookup) — which assignsx_fc_doc_indexand overridesnamefrom the journal-defaultINV/2026/xxxxtoIN-<parent>orIN-<parent>-NN.
- For
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 ownir.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:
- Reads
move_type. - If not customer-facing (
out_invoice/out_refund) → pass. - Else, look for
fp_from_so_invoice=Truein context ORinvoice_originresolving to an existingsale.order. - 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_woderivation uses(job.name or '').split('/')[-1]which assumedWH/JOB/prefix. Update to either strip the newWO-prefix or simply show fulljob.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.orderform: after confirm, show bothx_fc_quote_ref(small grey "Originally quoted as Q202605-200") andname(big SO-30000 heading).- All child forms: show
name(no change to layout). - All search views referencing
WH/JOB/orINV/prefixes indecoration-infostyle 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):
<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_actualat 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.orderand 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
- 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_idlink records the relationship; the original name stays. - Quote sequence migration. The
number_next_actual=200is illustrative. Confirmed value from the user before the spec is implemented (he stated "Q202605-200" as the format with200as the example counter, so we start there or at any agreed value). - Reports not updating display label. The WO Detail's
short_woderivation is the one known concrete report-side break. The rest readnameraw and don't need template changes. - Manual job creation outside the SO flow. Users may manually create an
fp.jobfrom 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:
- Add
fp.parent.numbered.mixinabstract model + sequence data. - Add SO fields (
x_fc_quote_ref,x_fc_parent_number, counters) andx_fc_doc_indexon each child. - Quote/SO rename in
sale.order.createandaction_confirm. - Block direct invoice creation (override on
account.move.create). - Wire each child model into the mixin:
fp.jobfirst (most critical), thenaccount.move(invoice/credit note), thenfp.certificate,fp.receiving,fusion.plating.delivery,fusion.plating.pickup.request, then quality models (NCR, CAPA, Hold, RMA). - Add
unlink()andwrite()overrides for immutability. - WO recipe-group rewrite of
_fp_native_jobs_for_so(replaces existingx_fc_wo_group_taggrouping). - View tweaks: SO form quote ref display.
- Fix WO Detail report's
short_woderivation. - Full audit walkthrough on a fresh DB: create quote → confirm → ship → invoice → CoC → verify every doc shows parent-derived name.
End of design.