Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
gsinghpal 671820427a 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>
2026-05-12 12:28:52 -04:00

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

  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

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):

  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:

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_idpart.default_process_idcoating.recipe_idpart.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.

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):

<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.