diff --git a/fusion_plating/docs/superpowers/specs/2026-04-22-sub5-order-line-fields-design.md b/fusion_plating/docs/superpowers/specs/2026-04-22-sub5-order-line-fields-design.md new file mode 100644 index 00000000..58939843 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-04-22-sub5-order-line-fields-design.md @@ -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 + + Fusion Plating Serial Number + fp.serial + FP-SN- + 5 + + + Fusion Plating Job Number + fp.job.number + FP-JOB- + 5 + +``` + +### 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.*