53 KiB
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 5–20 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 A–C, 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 |
Phase F — Quotes List View (related but separate workflow)
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 haspartner_id,surface_area, revisionsfusion_plating_configurator/models/fp_coating_config.py— already has UOM, pricefusion_plating_configurator/models/fp_customer_price_list.py— already supports_find_pricefusion_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(Many2oneres.partner) — Bill To. Domain: child of partner_id with type=invoice, or partner_id itself.partner_shipping_id(Many2oneres.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. Becomescommitment_dateon SO.line_ids(One2manyfp.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 Ax_fc_planned_start_date(Date) — Phase Ax_fc_internal_deadline(Date) — Phase A- (Customer deadline already maps to Odoo's
commitment_date.) x_fc_is_blanket_order(Boolean) — Phase Bx_fc_block_partial_shipments(Boolean) — Phase B
sale.order.line (modified)
New x_fc_* fields:
x_fc_treatment_ids(M2Mfp.treatment) — Phase Ax_fc_part_deadline(Date) — Phase Ax_fc_wo_group_tag(Char) — Phase Bx_fc_part_wo_description(Text) — Phase Cx_fc_start_at_node_id(Many2onefusion.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.pywith 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"
Task A2: Header-field additions + line O2M link on wizard
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_uomcreate_new_revision,new_drawing_file,new_drawing_filename,revision_notecoating_config_id,quantity,unit_price,line_subtotal,rush_orderdescription_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_orderto 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_revisionhelper
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 & 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)
B1–B2: Blanket + Block Partial flags
- Add
x_fc_is_blanket_order,x_fc_block_partial_shipmentsonsale.order. - Wizard header toggles.
stock.pickinghook that refuses partial pick whenx_fc_block_partial_shipments=True.
B3: Work Order grouping
x_fc_wo_group_tagonsale.order.line.fusion_plating_bridge_mrpWO 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.linerows.
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.orderthat formatscommitment_date - nowas "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_buttonor inline O2M ofmrp.workorderfiltered bymrp.production.x_fc_so_line_id → order_id = this. - D4 Notes split:
x_fc_internal_noteandx_fc_external_noteonsale.order(Odoo'snotegoes to one of them; migrate default tointernal). - D5 Archive line:
active=Falseonsale.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_reportsnew 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.assemblywith childrenfp.sale.order.assembly.line; each assembly has a parentsale.order.line. Big scope — only if the client ships kits. - D12 Contact + phone on SO: Odoo has
partner_id.phone; surface via related fieldx_fc_contact_phone. - D13 Ship Via:
x_fc_ship_viaChar or Many2one todelivery.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_mrpWO 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_descriptionon sale.order.line.- Wizard line form exposes two textareas.
- Report template includes both on the travelling sheet.
C3: Quote/Price picker
quote_line_idon line (Phase A declared, Phase C wires it).- Onchange copies unit price from picked quote.
fp.quote.configuratorgains anx_fc_from_so_countcomputed field so popular quotes surface first.
C4: Update Defaults toggle
push_to_defaultson line (Phase A declared).- On
action_create_ordersuccess, iterates lines and writes coating/treatments/desc ontofp.part.catalog.
C5: One-Off Part toggle
is_one_offon line (Phase A declared).- If true and
part_catalog_idis set, we still reference the part but skip any revision bump and flag the part withx_fc_one_off_use_count += 1.
C6: Keyboard shortcut Cmd/Ctrl-I
- OWL patch on
ListRendereradds 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.orderin statesalewith 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.