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:
@@ -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.**
|
||||
Reference in New Issue
Block a user