Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-04-22-sub5-order-line-fields-design.md
gsinghpal bb9bcf45f8 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>
2026-04-22 22:48:14 -04:00

16 KiB

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.

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.

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

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:
    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

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:

<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:

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.

  • 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_configurator19.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.