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