docs(plating): Sub 5 design spec — order-line fields (serial, job#, thickness, revision)

fp.serial registry with smart-button traceability (SO, MO, Delivery,
Invoice, Part), fp.coating.thickness child table per coating config,
four new fields on sale.order.line propagating through to MO /
Delivery / Invoice via existing hooks. Revision picker with latest-
only default + switcher + snapshot Char. Reports pick up all four
via Sub 2's customer-line-header macro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 22:48:14 -04:00
parent 1393c9e6ac
commit bb9bcf45f8

View File

@@ -0,0 +1,411 @@
# Sub 5 — Order-Line Fields (Serial, Job#, Thickness, Revision)
**Date:** 2026-04-22
**Module scope:** `fusion_plating_configurator` (primary; owns SO line, part catalog, coating
config); field mirrors on `mrp.production`, `fusion.plating.delivery`, `account.move.line` via
`fusion_plating_bridge_mrp`, `fusion_plating_logistics`, and `fusion_plating_configurator`
respectively.
**Status:** Design approved; implementing in this session.
**Predecessor context:** Fine-Tuning Initiative, entry in `fusion_plating/CLAUDE.md`; builds on
Sub 2 (part data model) and Sub 3 (default process).
---
## 1. Scope
Four new fields on every `sale.order.line` plus propagation to MO, Delivery, and Invoice,
plus two new supporting models:
| Field | Model | Shape | Default |
|---|---|---|---|
| `x_fc_serial_id` | `sale.order.line` | M2O → `fp.serial` | None, optional, never auto-generated |
| `x_fc_job_number` | `sale.order.line` | Char | Auto-sequenced on SO confirm, editable |
| `x_fc_thickness_id` | `sale.order.line` | M2O → `fp.coating.thickness` (domain = coating config) | None, optional |
| `x_fc_revision_snapshot` | `sale.order.line` | Char | Filled from `part_catalog_id.revision` at save |
Plus:
- **New model** `fp.serial` — registry of serial numbers with smart-button traceability.
- **New model** `fp.coating.thickness` — child of `fp.coating.config`, one row per allowed thickness.
- **Revision picker UX** — the SO line Part M2O filters to `is_latest_revision=True` by default;
a secondary "Revision" selector lets users pick a prior revision.
### Out of scope
- Per-piece serial tracking (Sub 5 is one serial per SO line, matching Option A from brainstorm).
- Job# uniqueness enforcement (soft warn only, not SQL constraint).
- Customer-specific thickness overrides (Sub 6 territory if ever needed).
- Retroactive backfill of serial/job#/thickness on historical SOs/MOs.
---
## 2. Data Model
### 2.1 `fp.serial` (new model)
Registry of serial numbers. One record per "occurrence of a part on an order line" — same part
ordered six months later gets a different serial.
```python
class FpSerial(models.Model):
_name = 'fp.serial'
_description = 'Fusion Plating — Serial Number'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
name = fields.Char(required=True, tracking=True,
help='Customer-supplied serial or auto-generated sequence.')
company_id = fields.Many2one('res.company', required=True,
default=lambda s: s.env.company)
sale_order_line_id = fields.Many2one('sale.order.line',
string='Source Sale Order Line',
ondelete='set null', tracking=True)
sale_order_id = fields.Many2one(related='sale_order_line_id.order_id', store=True)
customer_id = fields.Many2one(related='sale_order_line_id.order_id.partner_id', store=True)
part_id = fields.Many2one(related='sale_order_line_id.x_fc_part_catalog_id', store=True)
# Reverse links (populated by x_fc_serial_id planted on each model).
production_ids = fields.One2many('mrp.production',
'x_fc_serial_id',
string='Manufacturing Orders')
delivery_ids = fields.One2many('fusion.plating.delivery',
'x_fc_serial_id',
string='Deliveries')
invoice_line_ids = fields.One2many('account.move.line',
'x_fc_serial_id',
string='Invoice Lines')
production_count = fields.Integer(compute='_compute_counts')
delivery_count = fields.Integer(compute='_compute_counts')
invoice_count = fields.Integer(compute='_compute_counts')
_sql_constraints = [
('fp_serial_name_company_uniq', 'unique(company_id, name)',
'Serial number must be unique within the company.'),
]
```
Smart-button form view with links to Sale Order, MO, Delivery, Invoice, Part.
### 2.2 `fp.coating.thickness` (new model)
Child of `fp.coating.config`, one row per allowed thickness for that coating.
```python
class FpCoatingThickness(models.Model):
_name = 'fp.coating.thickness'
_description = 'Coating Thickness Option'
_order = 'coating_config_id, sequence, value'
coating_config_id = fields.Many2one('fp.coating.config',
required=True, ondelete='cascade')
value = fields.Float(digits=(10, 4), required=True)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
('microns', 'microns (µm)'),
('inches', 'inches'),
('mm', 'mm')],
required=True, default='mils')
sequence = fields.Integer(default=10)
display_name = fields.Char(compute='_compute_display_name', store=True)
active = fields.Boolean(default=True)
@api.depends('value', 'uom')
def _compute_display_name(self):
for rec in self:
rec.display_name = f'{rec.value:g} {dict(self._fields["uom"].selection).get(rec.uom, rec.uom) if rec.uom else ""}'.strip()
```
### 2.3 `sale.order.line` additions
```python
x_fc_serial_id = fields.Many2one('fp.serial', string='Serial Number',
ondelete='set null', copy=False, tracking=True)
x_fc_job_number = fields.Char(string='Job #', copy=False, tracking=True,
index=True)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness', string='Thickness',
domain="[('coating_config_id', '=', x_fc_coating_config_id)]",
ondelete='set null', tracking=True)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)', copy=False, readonly=True,
help='Revision letter/number at the moment this line was saved. '
'Preserved even if the part catalog revision is later edited '
'or the catalog row is removed.')
```
- `create()` + `write()` override populates `x_fc_revision_snapshot` from
`x_fc_part_catalog_id.revision` whenever the part changes (or the field is empty).
- Onchange on `x_fc_part_catalog_id` clears `x_fc_thickness_id` if the picked thickness doesn't
belong to the line's coating config.
### 2.4 Revision picker mechanics
Two approaches on the SO line form:
1. **Part M2O domain** — the Part field uses `domain="[('is_latest_revision', '=', True)]"`
context-filtered. The dropdown only shows one entry per part number (the latest rev).
2. **Revision Selector** — a new non-stored compute field on `sale.order.line`:
```python
x_fc_available_revisions = fields.Many2many(
'fp.part.catalog',
compute='_compute_available_revisions')
x_fc_revision_pick = fields.Many2one(
'fp.part.catalog', string='Revision',
domain="[('id', 'in', x_fc_available_revisions)]",
compute='_compute_revision_pick',
inverse='_inverse_revision_pick',
store=False)
```
When the user changes `x_fc_revision_pick` to a different revision of the same part number,
the inverse re-points `x_fc_part_catalog_id` to that revision.
### 2.5 `mrp.production`, `fusion.plating.delivery`, `account.move.line`
Each gains the same four fields:
- `x_fc_serial_id` (M2O, index for fast reverse lookup)
- `x_fc_job_number` (Char)
- `x_fc_thickness_id` (M2O)
- `x_fc_revision_snapshot` (Char)
Populated when the downstream record is created from its upstream SO line:
- **MO:** inject in `sale_mrp`'s procurement-to-MO hook (`_prepare_mo_vals` override in
`fusion_plating_bridge_mrp`).
- **Delivery:** already created from the MO in `fusion_plating_logistics` — add field carry-over
in the create hook.
- **Invoice line:** Sub 2 already overrides `_prepare_invoice_line` in `fusion_plating_configurator`;
extend that hook to carry the four new fields.
### 2.6 `fp.coating.config` additions
```python
thickness_option_ids = fields.One2many(
'fp.coating.thickness', 'coating_config_id',
string='Thickness Options')
```
### 2.7 Sequences
Two new sequences in `data/fp_sub5_sequence_data.xml`:
```xml
<record id="seq_fp_serial" model="ir.sequence">
<field name="name">Fusion Plating Serial Number</field>
<field name="code">fp.serial</field>
<field name="prefix">FP-SN-</field>
<field name="padding">5</field>
</record>
<record id="seq_fp_job_number" model="ir.sequence">
<field name="name">Fusion Plating Job Number</field>
<field name="code">fp.job.number</field>
<field name="prefix">FP-JOB-</field>
<field name="padding">5</field>
</record>
```
### 2.8 Job# auto-assign
Override `sale.order.action_confirm()` to assign `x_fc_job_number` to each line that doesn't
have one:
```python
def action_confirm(self):
res = super().action_confirm()
for so in self:
for line in so.order_line:
if not line.x_fc_job_number and not line.display_type:
line.x_fc_job_number = self.env['ir.sequence'].next_by_code('fp.job.number')
return res
```
### 2.9 "Generate Serial" button
On the `sale.order.line` form (expanded row), a button `action_generate_serial` that:
1. Creates `fp.serial` with `name = next_by_code('fp.serial')`, `sale_order_line_id = self.id`.
2. Writes `self.x_fc_serial_id = new_serial.id`.
3. Returns `False` (stays on SO form).
Typing a serial into the m2o widget uses standard "Create 'SN-12345'" which creates the
`fp.serial` with that exact name.
---
## 3. Views
### 3.1 Sale Order — order-lines tree
Inherit `view_order_form` — add columns:
- `x_fc_serial_id` (widget `many2one`, with create option)
- `x_fc_job_number` (Char)
- `x_fc_thickness_id` (with domain)
- `x_fc_revision_snapshot` (readonly)
On the line's optional drawer (expand-row): add "Generate Serial" button + "Revision" picker.
### 3.2 Direct-order wizard line
Extend `fp_direct_order_wizard_views.xml` to add the same 4 fields.
### 3.3 Coating Config form
New tab **Thickness Options** with inline O2M editor over `thickness_option_ids`.
### 3.4 `fp.serial` form + list + search
- **List:** Reference, Customer, Part, SO, Created On, State.
- **Form:** Header with name + state; body with source links; smart buttons for SO / MO / Delivery / Invoice / Part.
- **Menu:** Plating → Sales → Serials.
### 3.5 MO, Delivery, Invoice form inherits
Add a small read-only block "Traceability" showing the four fields when populated.
---
## 4. Reports
All four fields are added to Sub 2's `customer-line-header` macro so they print automatically on
every report that already uses it:
- **CoC** (`fusion_plating_reports.report_coc`) — Serial, Job#, Thickness, Rev snapshot
- **Packing slip** (`report_fp_packing_slip`) — Serial, Job#, Rev snapshot
- **Invoice** (`report_fp_invoice`) — Serial, Job#, Rev snapshot
- **WO Traveller** (`report_fp_job_traveller`) — Job#, Thickness, Rev snapshot
One macro edit, all four reports pick it up.
---
## 5. Security
- `fp.serial` — read for operator; CRUD for supervisor/manager/admin (same pattern as existing
configurator models).
- `fp.coating.thickness` — read for operator; CRUD for manager/admin.
- Company-scoped `ir.rule` on `fp.serial` via `company_id in company_ids`.
---
## 6. Migration
- New fields and models only; no backfill.
- Versions: `fusion_plating_configurator` → `19.0.12.0.0`,
`fusion_plating_bridge_mrp` → bump if sale_mrp hook change,
`fusion_plating_logistics` → bump if delivery carry-over added,
`fusion_plating_reports` → bump for macro updates.
- `fusion_plating_quality` (Sub 4) untouched.
---
## 7. Testing Strategy
### 7.1 End-to-end traceability
- Create SO line with part + coating + thickness; user types "SN-12345" → assert `fp.serial`
created and linked.
- Confirm SO → assert `x_fc_job_number` auto-populated with `FP-JOB-NNNNN` sequence.
- MO auto-created → assert `x_fc_serial_id`, `x_fc_job_number`, `x_fc_thickness_id`,
`x_fc_revision_snapshot` all carried.
- Delivery created → same four carried.
- Invoice posted → account.move.line has same four fields.
- Open the `fp.serial` record → smart-button counts show 1 SO, 1 MO, 1 Delivery, 1 Invoice.
### 7.2 Revision picker
- Part XYZ has revisions A, B, C (C is latest).
- SO line default → Part dropdown shows only Rev C.
- User switches Revision picker to Rev B → Part M2O re-points to Rev B's catalog row,
`x_fc_revision_snapshot = 'B'`.
- Save → admin later edits Rev B's `revision` Char to "B1" → SO line's snapshot still reads "B".
### 7.3 Thickness dropdown
- Coating config "ENP Class 4" has 3 thickness options (0.0005", 0.001", 0.0015").
- SO line picks coating → thickness dropdown shows exactly those 3 options.
- User changes coating config → thickness field resets (onchange clears it if no longer valid).
### 7.4 Generate Serial button
- Click on SO line → new `fp.serial` with name "FP-SN-00001" created and assigned.
### 7.5 Free-text serial
- User types "CUST-999" into x_fc_serial_id → Odoo offers "Create 'CUST-999'" → click →
`fp.serial` created with that exact name, linked.
### 7.6 Unique constraint
- Create two `fp.serial` records with the same name in the same company → second raises
unique violation.
---
## 8. Defensive Measures (Forward-Looking, Per User's No-Refix Rule)
1. **Serial registry is Many2one, not Char** — when Sub 6 (contact profiles) adds per-
customer notification routing, the serial record is a stable join target for audit queries.
2. **`x_fc_revision_snapshot` is populated at save** — survives revision edits and catalog
cleanups. No migration will ever need to reconstruct it from history.
3. **Job# auto-assign lives in `action_confirm()`** — a single hook point. Changing the sequence
format or making it optional-by-default is one function edit.
4. **Thickness options are a child table, not a JSON blob on the config** — future
per-customer / per-part overrides can be layered as an additional domain filter without
schema change.
5. **Reports consume the existing macro** — no report-side changes beyond the macro edit means
future Sub 8 (receiving/inspection restructure) can safely re-lay out reports.
6. **All four fields are `copy=False`** — duplicating an SO line gets fresh serial / job# /
snapshot rather than stale data. Prevents silent cross-order data bleed.
---
## 9. File Manifest
```
fusion_plating_configurator/
├── __manifest__.py (version bump, data list additions)
├── models/
│ ├── __init__.py (+ fp_serial, fp_coating_thickness)
│ ├── fp_serial.py NEW
│ ├── fp_coating_thickness.py NEW
│ ├── fp_coating_config.py (thickness_option_ids inverse)
│ ├── sale_order.py (action_confirm override for job#)
│ ├── sale_order_line.py (4 new fields + compute)
│ └── account_move_line.py (4 new fields)
├── wizard/
│ ├── fp_direct_order_line.py (4 new fields mirror)
│ └── fp_direct_order_wizard.py (carry to SO line on create)
├── views/
│ ├── fp_serial_views.xml NEW
│ ├── fp_coating_config_views.xml (thickness tab)
│ ├── sale_order_views.xml (4 columns in tree)
│ └── res_config_settings_views.xml (—no change—)
├── wizard/
│ └── fp_direct_order_wizard_views.xml (4 columns)
├── data/
│ └── fp_sub5_sequence_data.xml NEW (fp.serial, fp.job.number)
├── security/
│ └── ir.model.access.csv (+ fp.serial, fp.coating.thickness)
fusion_plating_bridge_mrp/
├── __manifest__.py (version bump)
├── models/
│ └── mrp_production.py (4 new fields + _prepare_mo_vals carry)
fusion_plating_logistics/
├── __manifest__.py (version bump)
├── models/
│ └── fp_delivery.py (4 new fields + create hook)
fusion_plating_reports/
├── __manifest__.py (version bump)
└── report/
└── customer_line_header.xml (add Serial / Job# / Thickness / Rev)
```
---
## 10. Rollout
- Target: entech (LXC 111)
- Update order:
1. `fusion_plating_configurator` (new models + sequences)
2. `fusion_plating_bridge_mrp` (MO fields + carry-over hook)
3. `fusion_plating_logistics` (Delivery fields + carry-over)
4. `fusion_plating_reports` (macro update)
- Each `-u MODULE --stop-after-init` then restart.
---
*End of spec.*