Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-19-direct-order-rewrite.md
gsinghpal 54e56ed0e6 changes
2026-04-20 01:16:12 -04:00

53 KiB
Raw Blame History

Direct Order Wizard Rewrite — Implementation Plan

Status: Draft v1 — value matrix + phase A detailed. Phases B/C outlined; to be detailed once Steelhead reference screenshots are collected. Last updated: 2026-04-19

Goal: Replace the current single-line fp.direct.order.wizard with a comprehensive multi-line order-entry wizard on par with (and beyond) Steelhead's Create Sales Order + Add Parts flow, while keeping it approachable for a non-power-user estimator.

Architecture: Header + lines pattern. Existing fp.direct.order.wizard becomes the header carrying customer, PO, deadlines, addresses, fulfilment flags, and invoicing. A new transient fp.direct.order.line model carries per-part detail (part, treatments, qty, deadline, price, description, WO group). A single "Create & Confirm Order" submit creates the sale.order with one sale.order.line per wizard line and confirms it.

Tech Stack: Odoo 19, Python 3.12, PostgreSQL, OWL (for any custom widgets), XML views.


Why This Plan Exists

Current fp.direct.order.wizard supports one part per order. Real POs routinely list 520 parts. Steelhead's Create Sales Order → Add Parts flow covers this elegantly; this plan pulls the high-value pieces over and adds a few things Steelhead doesn't have (live subtotals, description templates, price-list lookup).


Feature Inventory — Value × Cost Matrix

This plan covers two scopes: (1) the order-entry wizard (Phases AC, in scope for this rewrite) and (2) the SO detail view enhancements that Steelhead shows after the order exists (Phase D — smaller, independent tasks that don't block the wizard work).

Value: ★★★★★ = every estimator will use it daily. ★ = edge case. Cost: S = < 2 hr, M = half day, L = 1 day+.

Phase A — P0 Must-Have (cannot ship without these)

# Feature Value Cost Source
A1 Multi-row parts grid with add/duplicate/delete ★★★★★ M Steelhead
A2 Multiple treatments per line (M2M) ★★★★★ M Steelhead
A3 Per-row Part Deadline (falls back to SO deadline) ★★★★ S Steelhead
A4 SO header dates: Planned Start + Internal + Customer Deadline ★★★★★ S Steelhead
A5 Customer Job Number ★★★★ S Steelhead
A6 Ship To / Bill To pickers (partner addresses) ★★★★ S Steelhead
A7 Live line subtotal + order total ★★★★ S ours extended
A8 Description template picker per line ★★★★ S ours kept

Phase B — P1 High-Value (ship soon after)

# Feature Value Cost Source
B1 Blanket Sales Order flag ★★★ S Steelhead
B2 Block Partial Shipments flag ★★★ S Steelhead
B3 Work Order grouping per line (New WO#1 / existing WO) ★★★★ L Steelhead
B4 Add Row From Prior Sales Order (repeat-order shortcut) ★★★★ M Steelhead
B5 Missing-info warning banner ★★★ S Steelhead
B6 Rush Order per row (moves from header to lines) ★★★ S ours refined

Observed from the Steelhead "Quotes" list page. Quotes = sale.order in state draft/sent in Odoo; confirmed orders = state sale. Most of these are XML list-view additions.

# Feature Value Cost Source
F1 Follow-Up column (next action user + date) ★★★★ M Steelhead — drives sales discipline
F2 Expires column + "No Expiration" default ★★★★ S Steelhead — Odoo has validity_date
F3 Email status pills: Draft / Draft-Sent / Draft-Opened / Order Received ★★★★ M Steelhead — needs mail tracking hook
F4 Signed column (e-signature status from bridge_sign) ★★★ S Steelhead
F5 Part Numbers column with "See More..." expansion ★★★ S Steelhead
F6 Process/Part Count summary column ★★ S Steelhead
F7 "From RFQ" toggle filter ★★★ S Steelhead — domain on x_fc_rfq_id
F8 Bulk select with checkboxes ★★ S Odoo-native
F9 Per-row action icons (Download / PDF) ★★ S Steelhead
F10 "New Quote" button in toolbar → opens configurator ★★★★ S Steelhead

Phase E — SO List View (independent, small, high visibility)

Observed from the Steelhead "Sales Orders" list page. Mostly Odoo list-view XML; no models harmed.

# Feature Value Cost Source
E1 View toggles: Show Invoiced Amount / Show Margins / View Blanket / View Archived ★★★★ S Steelhead
E2 Progress column "0/1 Complete" (WO completion ratio per SO) ★★★★ M Steelhead
E3 Margin column (computed total cost) ★★★ S Steelhead
E4 Invoiced Amount column ★★★ S Steelhead
E5 Inline action icons per row (PDF / Edit / Download Acknowledgement) ★★★ M Steelhead
E6 Filter preset chips (Status=Open pre-applied) ★★★ S Steelhead
E7 Creator avatar column with initials + colour ★★ S Steelhead
E8 Customer column with partner image ★★ S Odoo-native
E9 SO Internal Deadline column ★★★★ S Phase A brings the field; E9 exposes it
E10 Quick "New Sales Order" button in toolbar → opens direct-order wizard ★★★★★ S Steelhead

Phase D — SO Detail View Enhancements (independent of the wizard)

Observed from the Steelhead SO detail page. These live on the existing sale.order form, not the wizard, so they can land in parallel with or after Phase A.

# Feature Value Cost Source
D1 Live countdown display on deadlines ("in 2d 12h ago") ★★ S Steelhead
D2 BOM Items section — parts list with WO progress ★★★ M Steelhead
D3 Active Work Orders table embedded on SO page ★★★★ M Steelhead
D4 Split Internal Notes vs External Notes (customer-visible) ★★★★ S Steelhead
D5 Archive Line (soft delete) vs hard delete ★★★ S Steelhead
D6 Add Quoted Lines button (pull lines from a prior quote) ★★★ M Steelhead
D7 Sales Order Acknowledgement PDF generator ★★★ M Steelhead
D8 Margin display on header ★★★ S Steelhead
D9 Quick-nav link bar (Shipping / Invoices / NCRs / All Files / etc.) ★★★ S Steelhead
D10 SO vs WO perspective toggle on line list ★★ M Steelhead
D11 Assemblies section — hierarchical parts (sub-BOM on an SO line) ★★ L Steelhead
D12 Customer contact + phone on SO header ★★★ S Steelhead
D13 Ship Via (carrier) field ★★ S Steelhead
D14 Uploaded Files section on SO ★★ S Steelhead

Phase C — P2 Polish (nice when there's time)

# Feature Value Cost Source
C1 "To Node" start-point picker (re-work jobs) ★★ M Steelhead
C2 Split part description vs. WO-detail description ★★ S Steelhead
C3 Quote/Price picker (link line to prior quote) ★★★ M Steelhead
C4 Update Defaults toggle per line ★★ S Steelhead
C5 One-Off Part # toggle (don't save to catalog) ★★ S Steelhead
C6 Keyboard shortcut: Cmd/Ctrl-I = new line ★★ S Steelhead

Skipped — low value for our shop

Feature Why skip
Sector / industry tag Low information value; partner already has category.
Display Image Part record already carries drawings / STL.
Assign Location per row Plating parts live on racks, not bins.
Update Customer Defaults button Our onchange already pulls partner defaults.
Order Type custom input Overlaps with Coating field.
Create Multiple Part Numbers wizard Needs a spec-field system we don't have. Revisit after that foundation lands.
Sector / Display Image Low value.

Open Questions — pending screenshots / client input

These will be filled in as the client shares more Steelhead flows or decides preferences:

  • Q1: Exact field order and grouping — defer to final screenshot pass.
  • Q2: Work Order grouping semantics — what happens when "New WO#1" is picked on 3 lines: do all 3 become one MO/WO in Odoo, or one MO with a shared x_fc_wo_group_tag? Leaning toward the second.
  • Q3: "To Node" target — does this drive the first WO's state on confirm, or a field on the MO that the bridge_mrp WO generator respects?
  • Q4: Treatment selection UX — two-level Process + Treatment (Steelhead) vs. flat multi-select of coating configs (ours today). Client preference TBD.
  • Q5: Missing-info rules — exactly which fields count toward "missing"? Starting assumption: part + coating + qty + price required; deadline recommended.
  • Q6: Blanket-order release mechanics — when someone calls a release against a blanket, is that a child SO or a separate DO? Not in scope for this wizard but decides whether we flag releases at line level.

File Structure

fusion_plating_configurator/
├── wizard/
│   ├── fp_direct_order_wizard.py          # [MODIFIED] header only + create logic
│   ├── fp_direct_order_wizard_views.xml   # [MODIFIED] new form with notebook + lines table
│   ├── fp_direct_order_line.py            # [NEW] transient line model
│   └── fp_add_row_from_so_wizard.py       # [NEW — Phase B] sub-wizard for repeat-order pick
├── models/
│   ├── sale_order.py                      # [MODIFIED] add Customer Job #, deadlines, blanket/block-partial, WO group tag
│   └── sale_order_line.py                 # [NEW] x_fc_treatment_ids M2M, x_fc_part_deadline, x_fc_wo_group_tag
├── security/
│   └── ir.model.access.csv                # [MODIFIED] add access for fp.direct.order.line
└── views/
    ├── fp_direct_order_wizard_views.xml   # moved from wizard/ — keep in wizard/
    └── sale_order_views.xml               # [MODIFIED] surface new SO-header fields on SO form

Existing files referenced:

  • fusion_plating_configurator/models/fp_part_catalog.py — already has partner_id, surface_area, revisions
  • fusion_plating_configurator/models/fp_coating_config.py — already has UOM, price
  • fusion_plating_configurator/models/fp_customer_price_list.py — already supports _find_price
  • fusion_plating_configurator/models/fp_sale_description_template.py — per-part / per-customer templates

Data Model Design

fp.direct.order.wizard (modified — becomes header)

Fields to keep (already there):

  • partner_id, po_number, po_attachment_file/filename, notes, invoice_strategy, deposit_percent, progress_initial_percent, currency_id

Fields to add (Phase A):

  • partner_invoice_id (Many2one res.partner) — Bill To. Domain: child of partner_id with type=invoice, or partner_id itself.
  • partner_shipping_id (Many2one res.partner) — Ship To. Similar domain with type=delivery.
  • customer_job_number (Char) — customer-internal ref, optional.
  • planned_start_date (Date) — when work should start. Default today.
  • internal_deadline (Date) — shop-floor target.
  • customer_deadline (Date) — contractual. Becomes commitment_date on SO.
  • line_ids (One2many fp.direct.order.line, wizard_id) — the grid.
  • total_amount (Monetary, compute) — sum of line subtotals.
  • total_qty (Integer, compute) — sum of line quantities.
  • total_line_count (Integer, compute) — for "Sales Order Total Part Count".
  • missing_info_msg (Char, compute) — banner text; empty = no banner.

Fields to add (Phase B):

  • is_blanket_order (Boolean) — tags the SO.
  • block_partial_shipments (Boolean) — forbids partial deliveries.

Fields to remove from wizard (move to line):

  • part_catalog_id, coating_config_id, quantity, unit_price, line_subtotal, rush_order, delivery_method (keep delivery_method at header), create_new_revision, new_drawing_file/filename, revision_note, description_template_id, line_description

fp.direct.order.line (NEW)

class FpDirectOrderLine(models.TransientModel):
    _name = 'fp.direct.order.line'
    _description = 'Direct Order Line'
    _order = 'sequence, id'

    wizard_id = fields.Many2one('fp.direct.order.wizard', required=True, ondelete='cascade')
    sequence = fields.Integer(default=10)

    # Part
    part_catalog_id = fields.Many2one(
        'fp.part.catalog',
        domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]",
        required=True,
    )
    part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
    part_revision = fields.Char(related='part_catalog_id.revision', readonly=True)

    # New revision (inline, Phase A keeps this from old wizard)
    create_new_revision = fields.Boolean(string='New Revision')
    new_drawing_file = fields.Binary()
    new_drawing_filename = fields.Char()
    revision_note = fields.Char()

    # Treatments (M2M — multiple coatings/treatments per line)
    coating_config_id = fields.Many2one(
        'fp.coating.config',
        string='Primary Treatment',
        required=True,
    )
    treatment_ids = fields.Many2many(
        'fp.treatment',
        string='Additional Treatments',
        help='Extra pre/post treatments applied to this line.',
    )

    # Qty / price
    quantity = fields.Integer(default=1, required=True)
    currency_id = fields.Many2one(related='wizard_id.currency_id')
    unit_price = fields.Monetary(currency_field='currency_id')
    line_subtotal = fields.Monetary(compute='_compute_line_subtotal', currency_field='currency_id')

    # Scheduling
    part_deadline = fields.Date(
        help='Per-line deadline. Defaults to SO customer deadline if blank.',
    )

    # Fulfilment
    rush_order = fields.Boolean()

    # Description
    description_template_id = fields.Many2one('fp.sale.description.template')
    line_description = fields.Text()

    # --- Phase B ---
    wo_group_tag = fields.Char(
        string='Work Order Group',
        help='Lines sharing a tag (e.g. "WO#1") are batched into one MO on confirm.',
    )

    # --- Phase C ---
    part_wo_description = fields.Text(
        string='On Work Order',
        help='Extra detail that appears on the work order travelling sheet.',
    )
    start_at_node_id = fields.Many2one(
        'fusion.plating.process.node',
        string='Start at Node',
        help='For re-work — pick the recipe node where this job should begin.',
    )
    quote_line_id = fields.Many2one(
        'fp.quote.configurator',
        string='Linked Quote',
    )
    push_to_defaults = fields.Boolean(
        string='Save as Default',
        help='After submit, write this line\'s coating/treatments/desc back onto the part.',
    )
    is_one_off = fields.Boolean(
        string='One-off Part',
        help='Do not save this part in the catalog after the order is created.',
    )

sale.order (modified)

New x_fc_* fields:

  • x_fc_customer_job_number (Char) — Phase A
  • x_fc_planned_start_date (Date) — Phase A
  • x_fc_internal_deadline (Date) — Phase A
  • (Customer deadline already maps to Odoo's commitment_date.)
  • x_fc_is_blanket_order (Boolean) — Phase B
  • x_fc_block_partial_shipments (Boolean) — Phase B

sale.order.line (modified)

New x_fc_* fields:

  • x_fc_treatment_ids (M2M fp.treatment) — Phase A
  • x_fc_part_deadline (Date) — Phase A
  • x_fc_wo_group_tag (Char) — Phase B
  • x_fc_part_wo_description (Text) — Phase C
  • x_fc_start_at_node_id (Many2one fusion.plating.process.node) — Phase C

UI Layout — wizard form

┌─ New Direct Order ────────────────────────────── ◩ ✕ ┐
│                                                       │
│  [ Missing Info banner, amber, shows only if needed ] │
│                                                       │
│  ┌─── CUSTOMER ──────────────┐ ┌─── PURCHASE ORDER ─┐│
│  │ Customer    [___▾]         │ │ Customer PO #  [_]││
│  │ Customer Job #  [_]        │ │ PO Document   [📎]││
│  │ Bill To     [___▾]         │ │ Customer Deadline ││
│  │ Ship To     [___▾]         │ │               [📅]││
│  └────────────────────────────┘ └───────────────────┘│
│                                                       │
│  ┌─── SCHEDULING ─────────────────────────────────────┐
│  │ Planned Start  [📅]   Internal Deadline  [📅]     │
│  │ Customer Deadline is in PO Box, shown there        │
│  └────────────────────────────────────────────────────┘
│                                                       │
│  ┌─── LINES ──────────────────────────────────────────┐
│  │ ┌──────────────────────────────────────────────┐   │
│  │ │ Part │ Coat │ Tmnt │ Qty │ $ │ Dl │ Sub │ ✕ │ … │
│  │ ├──────────────────────────────────────────────┤   │
│  │ │ … rows …                                      │   │
│  │ └──────────────────────────────────────────────┘   │
│  │ [+ Add Line]  [+ From Prior SO]  [Total: $  1,200] │
│  └────────────────────────────────────────────────────┘
│                                                       │
│  ┌─── FULFILMENT & INVOICING ─────────────────────────┐
│  │ Delivery Method [__▾]    Invoice Strategy [__▾]   │
│  │ [☐] Is Blanket Order    [☐] Block Partial Ships   │
│  └────────────────────────────────────────────────────┘
│                                                       │
│  ┌─── NOTES ──────────────────────────────────────────┐
│  │ [multiline textarea]                               │
│  └────────────────────────────────────────────────────┘
│                                                       │
│  [Create & Confirm Order]           [Save Draft][✕]  │
└───────────────────────────────────────────────────────┘

Each expand-row on a line reveals: new-revision fields, description template + tweak, WO group, To Node, quote link.


Phase A — Task Breakdown (detailed)

Task A1: Stub the line model + minimum scaffolding

Files:

  • Create: fusion_plating_configurator/wizard/fp_direct_order_line.py

  • Modify: fusion_plating_configurator/wizard/__init__.py

  • Modify: fusion_plating_configurator/security/ir.model.access.csv

  • Step 1: Create fp_direct_order_line.py with minimum fields

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models


class FpDirectOrderLine(models.TransientModel):
    _name = 'fp.direct.order.line'
    _description = 'Direct Order Line'
    _order = 'sequence, id'

    wizard_id = fields.Many2one(
        'fp.direct.order.wizard', required=True, ondelete='cascade',
    )
    sequence = fields.Integer(default=10)

    part_catalog_id = fields.Many2one(
        'fp.part.catalog', string='Part', required=True,
    )
    coating_config_id = fields.Many2one(
        'fp.coating.config', string='Primary Treatment', required=True,
    )
    quantity = fields.Integer(string='Qty', default=1, required=True)
    currency_id = fields.Many2one(related='wizard_id.currency_id')
    unit_price = fields.Monetary(
        string='Unit Price', currency_field='currency_id',
    )
    line_subtotal = fields.Monetary(
        string='Subtotal', currency_field='currency_id',
        compute='_compute_line_subtotal',
    )

    @api.depends('quantity', 'unit_price')
    def _compute_line_subtotal(self):
        for rec in self:
            rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
  • Step 2: Register in wizard __init__.py
from . import fp_direct_order_wizard
from . import fp_direct_order_line
from . import fp_part_catalog_import_wizard
  • Step 3: Add ACL row

Append to security/ir.model.access.csv:

access_fp_direct_order_line_user,fp.direct.order.line.user,model_fp_direct_order_line,base.group_user,1,1,1,1
  • Step 4: Update module on local Docker dev and verify model registered
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | tail -5
docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'EOF'
print(env['fp.direct.order.line']._fields.keys())
EOF

Expected: field list includes wizard_id, part_catalog_id, line_subtotal.

  • Step 5: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_line.py \
  fusion_plating_configurator/wizard/__init__.py \
  fusion_plating_configurator/security/ir.model.access.csv
git commit -m "feat(configurator): stub fp.direct.order.line model"

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py

  • Step 1: Add new fields to wizard class

Open fp_direct_order_wizard.py. Locate the end of the field declarations (around line 116, before @api.depends). Add:

    # --- Header: addresses ---
    partner_invoice_id = fields.Many2one(
        'res.partner', string='Invoice Address',
        domain="['|', ('id', '=', partner_id), "
               "('parent_id', '=', partner_id), ('type', 'in', ['invoice', 'contact'])]",
    )
    partner_shipping_id = fields.Many2one(
        'res.partner', string='Delivery Address',
        domain="['|', ('id', '=', partner_id), "
               "('parent_id', '=', partner_id), ('type', 'in', ['delivery', 'contact'])]",
    )

    # --- Header: scheduling ---
    customer_job_number = fields.Char(
        string='Customer Job #',
        help="Customer's internal job number. Appears on our work orders "
             "and the customer's invoice for easy cross-referencing.",
    )
    planned_start_date = fields.Date(
        string='Planned Start', default=fields.Date.context_today,
    )
    internal_deadline = fields.Date(string='Internal Deadline')
    customer_deadline = fields.Date(string='Customer Deadline')

    # --- Lines ---
    line_ids = fields.One2many(
        'fp.direct.order.line', 'wizard_id', string='Order Lines',
    )
    total_amount = fields.Monetary(
        compute='_compute_totals', currency_field='currency_id',
    )
    total_qty = fields.Integer(compute='_compute_totals')
    total_line_count = fields.Integer(compute='_compute_totals')

    # --- Missing info banner ---
    missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
  • Step 2: Add the compute methods

After _compute_line_subtotal (existing around line 118), add:

    @api.depends('line_ids.line_subtotal', 'line_ids.quantity')
    def _compute_totals(self):
        for rec in self:
            rec.total_amount = sum(rec.line_ids.mapped('line_subtotal'))
            rec.total_qty = sum(rec.line_ids.mapped('quantity'))
            rec.total_line_count = len(rec.line_ids)

    @api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id',
                 'line_ids.unit_price', 'line_ids.quantity')
    def _compute_missing_info_msg(self):
        for rec in self:
            missing = []
            for line in rec.line_ids:
                if not line.part_catalog_id:
                    missing.append('part')
                if not line.coating_config_id:
                    missing.append('coating')
                if not line.unit_price:
                    missing.append('price')
            if missing:
                rec.missing_info_msg = (
                    'Some lines are missing quote information — verify '
                    'before confirming.'
                )
            else:
                rec.missing_info_msg = False
  • Step 3: Add onchange — reset addresses when partner changes

Append to _onchange_partner_id:

        if self.partner_id:
            self.partner_invoice_id = (
                self.partner_id.address_get(['invoice']).get('invoice')
            )
            self.partner_shipping_id = (
                self.partner_id.address_get(['delivery']).get('delivery')
            )
  • Step 4: Deploy + quick shell check
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL" || echo "OK"
  • Step 5: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py
git commit -m "feat(configurator): add header fields + line O2M to direct order wizard"

Task A3: Remove single-line fields from wizard + migrate action_create_order to loop

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py

  • Step 1: Delete the moved-to-line fields

Remove these field declarations (they now live on fp.direct.order.line):

  • part_catalog_id, part_number, current_revision, surface_area, surface_area_uom
  • create_new_revision, new_drawing_file, new_drawing_filename, revision_note
  • coating_config_id, quantity, unit_price, line_subtotal, rush_order
  • description_template_id, line_description

Also delete the now-orphan onchange methods (_onchange_description_template, _onchange_suggest_template, _onchange_lookup_price) and the _compute_line_subtotal method — they move to the line model in Task A4.

  • Step 2: Rewrite action_create_order to loop over lines

Replace the existing action_create_order method:

    def action_create_order(self):
        """Create and confirm the sale order with one SO line per wizard line."""
        self.ensure_one()
        if not self.line_ids:
            raise UserError(_('Add at least one part line.'))

        # 1. Save the PO attachment once
        po_att = self.env['ir.attachment'].create({
            'name': self.po_attachment_filename or 'po.pdf',
            'datas': self.po_attachment_file,
            'mimetype': 'application/pdf',
        })

        # 2. Find or create the generic plating service product
        product = self.env['product.product'].search(
            [('default_code', '=', 'FP-SERVICE')], limit=1,
        )
        if not product:
            product = self.env['product.product'].create({
                'name': 'Plating Service',
                'default_code': 'FP-SERVICE',
                'type': 'service',
                'list_price': 0,
                'sale_ok': True,
                'purchase_ok': False,
            })

        # 3. Build SO header
        so_vals = {
            'partner_id': self.partner_id.id,
            'partner_invoice_id': (
                self.partner_invoice_id.id or self.partner_id.id
            ),
            'partner_shipping_id': (
                self.partner_shipping_id.id or self.partner_id.id
            ),
            'x_fc_po_number': self.po_number,
            'x_fc_po_attachment_id': po_att.id,
            'x_fc_po_received': True,
            'x_fc_customer_job_number': self.customer_job_number or False,
            'x_fc_planned_start_date': self.planned_start_date,
            'x_fc_internal_deadline': self.internal_deadline,
            'commitment_date': self.customer_deadline,
            'x_fc_invoice_strategy': self.invoice_strategy,
            'x_fc_deposit_percent': self.deposit_percent,
            'x_fc_progress_initial_percent': self.progress_initial_percent,
            'origin': 'Direct Order',
            'note': self.notes or False,
            'order_line': [],
        }

        # 4. One SO line per wizard line
        for line in self.line_ids:
            part = line._get_or_bump_revision()  # revision handling moved to line
            header = '%s - %s Rev %s (x%d)' % (
                line.coating_config_id.name,
                part.name,
                part.revision or part.revision_number,
                line.quantity,
            )
            extended = (line.line_description or '').strip()
            line_desc = (header + '\n\n' + extended) if extended else header
            if line.description_template_id:
                line.description_template_id._register_usage()
            so_vals['order_line'].append((0, 0, {
                'product_id': product.id,
                'name': line_desc,
                'product_uom_qty': line.quantity,
                'price_unit': line.unit_price or 0.0,
                'x_fc_part_catalog_id': part.id,
                'x_fc_coating_config_id': line.coating_config_id.id,
                'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
                'x_fc_part_deadline': line.part_deadline,
                'x_fc_rush_order': line.rush_order,
            }))

        so = self.env['sale.order'].create(so_vals)
        so.action_confirm()
        so.message_post(body=_(
            'Direct order created from PO %s with %d line(s). '
            'Quotation stage skipped.'
        ) % (self.po_number, len(self.line_ids)))

        return {
            'type': 'ir.actions.act_window',
            'name': _('Sale Order'),
            'res_model': 'sale.order',
            'res_id': so.id,
            'view_mode': 'form',
            'target': 'current',
        }
  • Step 3: Deploy + verify no load errors
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
  • Step 4: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py
git commit -m "refactor(configurator): loop direct order wizard over line_ids"

Task A4: Fill out fp.direct.order.line with all per-line logic

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_line.py

  • Step 1: Add remaining line fields

Extend the line model with:

    # Part details
    part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
    part_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
    surface_area = fields.Float(
        related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
    )

    # New revision
    create_new_revision = fields.Boolean(string='This is a New Revision')
    new_drawing_file = fields.Binary()
    new_drawing_filename = fields.Char()
    revision_note = fields.Char()

    # Extra treatments
    treatment_ids = fields.Many2many(
        'fp.treatment', string='Additional Treatments',
    )

    # Scheduling / fulfilment
    part_deadline = fields.Date(string='Part Deadline')
    rush_order = fields.Boolean(string='Rush')

    # Description
    description_template_id = fields.Many2one(
        'fp.sale.description.template', string='Description Template',
        domain="[('active','=',True), '|', '|', '|', "
               "  ('part_catalog_id','=',part_catalog_id), "
               "  ('part_catalog_id','=',False), "
               "  ('partner_id','=',parent.partner_id), "
               "  ('coating_config_id','=',coating_config_id)]",
    )
    line_description = fields.Text()
  • Step 2: Move auto-price lookup and template suggestion onchange onto the line
    @api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
    def _onchange_lookup_price(self):
        if self.unit_price:
            return
        if not (self.wizard_id.partner_id and self.coating_config_id):
            return
        price = self.env['fp.customer.price.list']._find_price(
            self.wizard_id.partner_id.id,
            self.coating_config_id.id,
            quantity=self.quantity or 1,
        )
        if price:
            self.unit_price = price.unit_price

    @api.onchange('description_template_id')
    def _onchange_description_template(self):
        if self.description_template_id:
            self.line_description = self.description_template_id.description

    @api.onchange('part_catalog_id', 'coating_config_id')
    def _onchange_suggest_template(self):
        if self.description_template_id or self.line_description:
            return
        Template = self.env['fp.sale.description.template']
        if self.part_catalog_id:
            match = Template.search([
                ('active', '=', True),
                ('part_catalog_id', '=', self.part_catalog_id.id),
            ], order='sequence', limit=1)
            if match:
                self.description_template_id = match.id
                self.line_description = match.description
                return
        if self.wizard_id.partner_id:
            match = Template.search([
                ('active', '=', True),
                ('part_catalog_id', '=', False),
                ('partner_id', '=', self.wizard_id.partner_id.id),
            ], order='sequence', limit=1)
            if match:
                self.description_template_id = match.id
                self.line_description = match.description
                return
        if self.coating_config_id:
            match = Template.search([
                ('active', '=', True),
                ('part_catalog_id', '=', False),
                ('partner_id', '=', False),
                ('coating_config_id', '=', self.coating_config_id.id),
            ], order='sequence', limit=1)
            if match:
                self.description_template_id = match.id
                self.line_description = match.description
  • Step 3: Add _get_or_bump_revision helper
    def _get_or_bump_revision(self):
        """Return the part to use for the SO line, optionally bumping revision."""
        self.ensure_one()
        part = self.part_catalog_id
        if not self.create_new_revision:
            return part
        if not self.new_drawing_file:
            raise UserError(_(
                'Upload the new drawing before confirming this line.'
            ))
        drawing_att = self.env['ir.attachment'].create({
            'name': self.new_drawing_filename or 'drawing.pdf',
            'datas': self.new_drawing_file,
            'res_model': 'fp.part.catalog',
            'res_id': part.id,
        })
        part.action_create_revision()
        new_rev = self.env['fp.part.catalog'].search([
            ('parent_part_id', '=', (part.parent_part_id or part).id),
            ('is_latest_revision', '=', True),
        ], limit=1, order='revision_number desc')
        if new_rev:
            new_rev.write({'revision_note': self.revision_note or False})
            fname = (self.new_drawing_filename or '').lower()
            if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
                new_rev.model_attachment_id = drawing_att.id
            else:
                new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
            return new_rev
        return part
  • Step 4: Deploy
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
  • Step 5: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_line.py
git commit -m "feat(configurator): full direct-order line with price lookup + rev bump"

Task A5: Add x_fc_* fields on sale.order and sale.order.line

Files:

  • Modify: fusion_plating_configurator/models/sale_order.py

  • Create: fusion_plating_configurator/models/sale_order_line.py

  • Modify: fusion_plating_configurator/models/__init__.py

  • Step 1: Add new header fields to sale_order.py

Open sale_order.py and add:

    x_fc_customer_job_number = fields.Char(
        string='Customer Job #',
        help="Customer's internal job number for cross-referencing.",
        tracking=True,
    )
    x_fc_planned_start_date = fields.Date(string='Planned Start Date')
    x_fc_internal_deadline = fields.Date(string='Internal Deadline')
  • Step 2: Create sale_order_line.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models


class SaleOrderLine(models.Model):
    _inherit = 'sale.order.line'

    x_fc_part_catalog_id = fields.Many2one(
        'fp.part.catalog', string='Part', tracking=True,
    )
    x_fc_coating_config_id = fields.Many2one(
        'fp.coating.config', string='Primary Treatment',
    )
    x_fc_treatment_ids = fields.Many2many(
        'fp.treatment', string='Additional Treatments',
    )
    x_fc_part_deadline = fields.Date(string='Part Deadline')
    x_fc_rush_order = fields.Boolean(string='Rush')
  • Step 3: Register in models/__init__.py

Append:

from . import sale_order_line
  • Step 4: Deploy
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5
  • Step 5: Commit
git add fusion_plating_configurator/models/
git commit -m "feat(configurator): x_fc_* fields on sale.order and sale.order.line"

Task A6: Wizard form view with notebook + line tree

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml

  • Step 1: Rewrite the form view

Replace the existing view_fp_direct_order_wizard_form with a notebook-style layout. Full markup (wrap in the existing <record>):

<record id="view_fp_direct_order_wizard_form" model="ir.ui.view">
    <field name="name">fp.direct.order.wizard.form</field>
    <field name="model">fp.direct.order.wizard</field>
    <field name="arch" type="xml">
        <form string="New Direct Order">
            <div class="alert alert-warning"
                 invisible="not missing_info_msg">
                <field name="missing_info_msg" readonly="1" nolabel="1"/>
            </div>
            <sheet>
                <group>
                    <group string="Customer">
                        <field name="partner_id"/>
                        <field name="partner_invoice_id"/>
                        <field name="partner_shipping_id"/>
                        <field name="customer_job_number"/>
                    </group>
                    <group string="Purchase Order">
                        <field name="po_number"/>
                        <field name="po_attachment_file"
                               filename="po_attachment_filename"/>
                        <field name="po_attachment_filename" invisible="1"/>
                    </group>
                </group>
                <group>
                    <group string="Scheduling">
                        <field name="planned_start_date"/>
                        <field name="internal_deadline"/>
                        <field name="customer_deadline"/>
                    </group>
                    <group string="Invoicing">
                        <field name="invoice_strategy"/>
                        <field name="deposit_percent"
                               invisible="invoice_strategy != 'deposit'"/>
                        <field name="progress_initial_percent"
                               invisible="invoice_strategy != 'progress'"/>
                    </group>
                </group>
                <notebook>
                    <page string="Lines" name="lines">
                        <field name="line_ids">
                            <list editable="bottom">
                                <field name="sequence" widget="handle"/>
                                <field name="part_catalog_id"
                                       context="{'default_partner_id': parent.partner_id}"/>
                                <field name="coating_config_id"/>
                                <field name="quantity"/>
                                <field name="unit_price"/>
                                <field name="line_subtotal" sum="Total"/>
                                <field name="part_deadline"/>
                                <field name="rush_order" optional="hide"/>
                                <field name="currency_id" column_invisible="1"/>
                            </list>
                            <form string="Line">
                                <group>
                                    <group>
                                        <field name="part_catalog_id"/>
                                        <field name="coating_config_id"/>
                                        <field name="treatment_ids"
                                               widget="many2many_tags"/>
                                        <field name="quantity"/>
                                        <field name="unit_price"/>
                                        <field name="line_subtotal"/>
                                    </group>
                                    <group>
                                        <field name="part_deadline"/>
                                        <field name="rush_order"/>
                                        <field name="create_new_revision"/>
                                        <field name="new_drawing_file"
                                               filename="new_drawing_filename"
                                               invisible="not create_new_revision"/>
                                        <field name="new_drawing_filename"
                                               invisible="1"/>
                                        <field name="revision_note"
                                               invisible="not create_new_revision"/>
                                    </group>
                                </group>
                                <group string="Description">
                                    <field name="description_template_id"/>
                                    <field name="line_description"/>
                                </group>
                            </form>
                        </field>
                        <group>
                            <group>
                                <field name="total_line_count" readonly="1"/>
                                <field name="total_qty" readonly="1"/>
                            </group>
                            <group>
                                <field name="total_amount" readonly="1"/>
                                <field name="currency_id" invisible="1"/>
                            </group>
                        </group>
                    </page>
                    <page string="Notes" name="notes">
                        <field name="notes"/>
                    </page>
                </notebook>
            </sheet>
            <footer>
                <button string="Create &amp; Confirm Order"
                        name="action_create_order"
                        type="object" class="btn-primary"/>
                <button string="Cancel" class="btn-secondary"
                        special="cancel"/>
            </footer>
        </form>
    </field>
</record>
  • Step 2: Deploy + open the wizard in the UI
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL|Registry loaded" | tail -5

Open http://localhost:8069 → Plating → Sales → New Direct Order. Verify the header + lines table renders and rows can be added/removed.

  • Step 3: Smoke test — create a 3-line order

Pick a customer, upload a PO, add 3 lines with different parts/coatings/quantities. Hit Create & Confirm. Verify the resulting SO has 3 lines with matching qty/price/deadline and is in state sale.

  • Step 4: Commit
git add fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
git commit -m "feat(configurator): notebook wizard form with lines tree"

Task A7: Surface new header fields on sale.order form

Files:

  • Modify: fusion_plating_configurator/views/sale_order_views.xml

  • Step 1: Add fields to the existing SO form inheritance

Open the file, locate where x_fc_po_number is already rendered, and add (nearby):

<field name="x_fc_customer_job_number"/>
<field name="x_fc_planned_start_date"/>
<field name="x_fc_internal_deadline"/>

And for the line tree inside SO form, add:

<xpath expr="//field[@name='order_line']//list/field[@name='product_uom_qty']"
       position="before">
    <field name="x_fc_part_catalog_id" optional="show"/>
    <field name="x_fc_coating_config_id" optional="show"/>
    <field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
    <field name="x_fc_part_deadline" optional="hide"/>
    <field name="x_fc_rush_order" optional="hide"/>
</xpath>
  • Step 2: Deploy + open an SO
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init 2>&1 | grep -E "ERROR|CRITICAL" | tail -3

Open the SO created in Task A6 and verify all x_fc_* fields are visible.

  • Step 3: Commit
git add fusion_plating_configurator/views/sale_order_views.xml
git commit -m "feat(configurator): surface new x_fc_* fields on sale order form"

Task A8: Bump module version + manifest update

Files:

  • Modify: fusion_plating_configurator/__manifest__.py

  • Step 1: Bump version and add new files

Bump version by one minor (e.g. 19.0.3.0.0). Add the new files to data:

'data': [
    # ... existing ...
    # already present: views/sale_order_views.xml, wizard/fp_direct_order_wizard_views.xml
],

The line model is picked up via __init__.py — no extra data entry needed.

  • Step 2: Deploy on odoo-entech for the first end-to-end UAT
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_configurator --stop-after-init\" 2>&1 | grep -E \"ERROR|CRITICAL|Registry loaded\" | tail -5 && systemctl start odoo'"
  • Step 3: Smoke test on staging — repeat Task A6 step 3 against real client data

  • Step 4: Commit + push

git add fusion_plating_configurator/__manifest__.py
git commit -m "chore(configurator): bump to 19.0.3.0.0 for multi-line direct order"
git push origin main

Phase B — Task Outline (to be detailed in next pass)

B1B2: Blanket + Block Partial flags

  • Add x_fc_is_blanket_order, x_fc_block_partial_shipments on sale.order.
  • Wizard header toggles.
  • stock.picking hook that refuses partial pick when x_fc_block_partial_shipments=True.

B3: Work Order grouping

  • x_fc_wo_group_tag on sale.order.line.
  • fusion_plating_bridge_mrp WO generator groups lines by tag into one MO.
  • Tag picker in the wizard line form: free-text, with suggestions from existing tags on the same wizard.

B4: Add Row From Prior Sales Order

  • New sub-wizard fp.add.row.from.so.wizard.
  • Shows prior SO lines for the same customer; user multi-selects; we create matching fp.direct.order.line rows.

B5: Missing-info warning banner

  • Already wired in Phase A with missing_info_msg; Phase B extends to more specific per-line chips in the line tree (e.g. amber dot in the row).

B6: Move Rush from header to line

  • Already a line field in Phase A. Phase B drops it from the header if it ever lived there.

Phase D — SO Detail View Tasks (outline)

  • D1 Live countdown: computed Char on sale.order that formats commitment_date - now as "in 2d 12h" / "overdue by 3h". Use in widget or label.
  • D2 BOM Items: already partially there via order_line; add a grouped tree by part showing per-part completion % from linked MOs.
  • D3 Active WOs: stat_button or inline O2M of mrp.workorder filtered by mrp.production.x_fc_so_line_id → order_id = this.
  • D4 Notes split: x_fc_internal_note and x_fc_external_note on sale.order (Odoo's note goes to one of them; migrate default to internal).
  • D5 Archive line: active=False on sale.order.line — Odoo supports it natively; add an "Archive" button and a search filter on the SO line view.
  • D6 Add Quoted Lines: mini-wizard that shows the customer's prior quotes with sticky line selection.
  • D7 SO Acknowledgement PDF: fusion_plating_reports new report + template + "Generate Acknowledgement" smart button that emails customer.
  • D8 Margin: compute = total cost rollup from coating configs.
  • D9 Quick-nav: inline view stub that shows chips linking to filtered lists (Receiving, NCRs, Invoices etc.) filtered by this SO.
  • D10 SO/WO toggle: view-mode switch in Odoo is already there; add a custom kanban of lines grouped by WO as an alternative view.
  • D11 Assemblies: new model fp.sale.order.assembly with children fp.sale.order.assembly.line; each assembly has a parent sale.order.line. Big scope — only if the client ships kits.
  • D12 Contact + phone on SO: Odoo has partner_id.phone; surface via related field x_fc_contact_phone.
  • D13 Ship Via: x_fc_ship_via Char or Many2one to delivery.carrier.
  • D14 Uploaded Files: already there via attachments; surface a filtered file widget in the SO form.

Phase C — Task Outline (polish)

C1: "To Node" start-point picker

  • Already a line field (start_at_node_id).
  • fusion_plating_bridge_mrp WO generator skips ancestor nodes when set.
  • UI: Many2one domain filtered by coating_config_id.recipe_id.

C2: Split part description vs. WO-detail description

  • x_fc_part_wo_description on sale.order.line.
  • Wizard line form exposes two textareas.
  • Report template includes both on the travelling sheet.

C3: Quote/Price picker

  • quote_line_id on line (Phase A declared, Phase C wires it).
  • Onchange copies unit price from picked quote.
  • fp.quote.configurator gains an x_fc_from_so_count computed field so popular quotes surface first.

C4: Update Defaults toggle

  • push_to_defaults on line (Phase A declared).
  • On action_create_order success, iterates lines and writes coating/treatments/desc onto fp.part.catalog.

C5: One-Off Part toggle

  • is_one_off on line (Phase A declared).
  • If true and part_catalog_id is set, we still reference the part but skip any revision bump and flag the part with x_fc_one_off_use_count += 1.

C6: Keyboard shortcut Cmd/Ctrl-I

  • OWL patch on ListRenderer adds the hotkey.
  • Low priority.

Risks & Mitigations

Risk Likelihood Mitigation
Existing SOs break when we add new x_fc_* on sale.order.line Medium All new fields are optional (no required=True).
Existing single-line wizard users mid-session when module upgrades Low Wizards are transient; no persistence.
Treatment M2M triggers too many onchanges Medium treatment_ids is pure data; no onchange-heavy logic. Price calc stays on coating_config_id.
bridge_mrp WO generator changes in B3 break existing single-line MOs High B3 touches MO-creation path; must add a regression test that confirms single-line still produces 1 MO / 1 WO set.
Template picker domain is stringly-typed — easy to break on refactor Low Covered in Phase A — same pattern as existing wizard.

Verification — how "done" looks

After Phase A:

  • An estimator can open New Direct Order.
  • Pick customer, PO#, customer job#, bill/ship addresses, three deadlines.
  • Add 5 lines with different parts, coatings, qty, price, deadline.
  • See running order total + per-line subtotal.
  • Hit Create & Confirm → lands on a sale.order in state sale with 5 matching lines.
  • The SO form shows the new x_fc_* fields and each line shows its coating / treatments / deadline.

After Phase B:

  • The same flow plus: toggle Blanket + Block Partial; tag lines WO#1 / WO#2; click "Add from Prior SO" and pick 3 lines from last month's PO.

After Phase C:

  • Line detail form lets you resume from a specific recipe node, split part vs. WO description, link to the original quote, and push values back to the part catalog.

Next Step

Confirm Phase A scope, then begin with Task A1. Screenshots and clarifications land in the "Open Questions" section and get absorbed into the relevant task before it's executed.