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

1236 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
### 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 `<record>`):
```xml
<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**
```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
<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:
```xml
<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**
```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)
### 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.