# 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 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) ```python 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** ```python # -*- 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`** ```python 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`: ```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** ```bash 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** ```bash 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: ```python # --- 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: ```python @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`: ```python 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** ```bash 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** ```bash 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: ```python 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** ```bash 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** ```bash 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: ```python # 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** ```python @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** ```python 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** ```bash 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** ```bash 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: ```python 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`** ```python # -*- 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: ```python from . import sale_order_line ``` - [ ] **Step 4: Deploy** ```bash 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** ```bash 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 ``): ```xml fp.direct.order.wizard.form fp.direct.order.wizard
``` - [ ] **Step 2: Deploy + open the wizard in the UI** ```bash 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** ```bash 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): ```xml ``` And for the line tree inside SO form, add: ```xml ``` - [ ] **Step 2: Deploy + open an SO** ```bash 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** ```bash 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`: ```python '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** ```bash 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** ```bash 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_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.