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