1236 lines
53 KiB
Markdown
1236 lines
53 KiB
Markdown
# 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 `<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 & 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)
|
||
|
||
### 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.
|