refactor(configurator): multi-line direct order wizard with notebook form

Tasks A3 + A6. Wizard rewritten as header + lines architecture:

- Header carries customer/addresses/PO/deadlines/invoicing/notes.
- One SO line created per fp.direct.order.line, carrying part,
  coating, treatments M2M, qty, price, per-line deadline, rush flag,
  and description.
- action_create_order loops wizard lines, invokes revision-bump
  helper, and builds order_line tuples with x_fc_* fields.
- Form view uses notebook (Lines tab with editable tree + drill-in
  form, Notes tab), amber missing-info banner at top, running totals
  at bottom. Customer deadline maps to Odoo commitment_date on SO.

Single-line fields and their computes/onchanges removed from wizard;
moved to fp.direct.order.line in task A4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 20:37:11 -04:00
parent 95db3aff0f
commit e34c1bcc8d
2 changed files with 206 additions and 306 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -11,90 +11,17 @@ class FpDirectOrderWizard(models.TransientModel):
"""Direct order entry for repeat customers.
Skips the quotation stage when the customer has already sent a PO.
Creates a sale.order and calls action_confirm() in one step.
Optionally bumps the part catalog revision when a new drawing is uploaded.
Creates a sale.order with one sale.order.line per wizard line and
calls action_confirm() in one step.
"""
_name = 'fp.direct.order.wizard'
_description = 'Fusion Plating Direct Order Entry'
_description = 'Fusion Plating - Direct Order Entry'
# ---- Customer ----
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
)
# Part selection
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part', required=True,
domain="[('partner_id', '=', partner_id), ('is_latest_revision', '=', True)]",
)
part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
current_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),
)
surface_area_uom = fields.Selection(
related='part_catalog_id.surface_area_uom', readonly=True,
)
# Revision upload (optional — creates a new revision of the part)
create_new_revision = fields.Boolean(
string='This is a New Revision',
help='Check if the customer sent an updated drawing or 3D model. '
'A new part revision will be created and linked to this order.',
)
new_drawing_file = fields.Binary(
string='New Drawing / 3D Model',
help='STEP, STL, IGES, or PDF. Used when creating a new revision.',
)
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(
string='Revision Note', help='What changed in this revision?',
)
# Order details
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True,
)
quantity = fields.Integer(string='Quantity', required=True, default=1)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit_price = fields.Monetary(
string='Unit Price', currency_field='currency_id',
help='Negotiated price per part. Leave blank to set later.',
)
line_subtotal = fields.Monetary(
string='Line Subtotal', currency_field='currency_id',
compute='_compute_line_subtotal',
)
rush_order = fields.Boolean(string='Rush Order')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method',
)
# PO (required — that's what makes this a "direct" order)
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# Invoice strategy (pulled from partner default if set)
invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy',
)
deposit_percent = fields.Float(string='Deposit %')
progress_initial_percent = fields.Float(
string='Progress — Initial %', default=50.0,
)
notes = fields.Text(string='Internal Notes')
# ---- Header additions (Phase A) ----
partner_invoice_id = fields.Many2one(
'res.partner', string='Invoice Address',
domain="['|', ('id', '=', partner_id), "
@@ -110,13 +37,46 @@ class FpDirectOrderWizard(models.TransientModel):
help="Customer's internal job number for cross-referencing. "
"Appears on work orders and invoices.",
)
# ---- Scheduling ----
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 (Phase A) ----
# ---- PO (required — that's what makes this a "direct" order) ----
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# ---- Fulfilment (order-level) ----
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method',
)
# ---- Currency + invoicing ----
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy',
)
deposit_percent = fields.Float(string='Deposit %')
progress_initial_percent = fields.Float(
string='Progress - Initial %', default=50.0,
)
# ---- Notes ----
notes = fields.Text(string='Internal Notes')
# ---- Lines ----
line_ids = fields.One2many(
'fp.direct.order.line', 'wizard_id', string='Order Lines',
)
@@ -132,32 +92,7 @@ class FpDirectOrderWizard(models.TransientModel):
# ---- Missing info banner ----
missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
# Description template picker — the domain is dynamically narrowed to
# this part's canned descriptions first. When no part is chosen it
# falls through to generic templates.
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','=',partner_id), "
" ('coating_config_id','=',coating_config_id)]",
help='Pick a saved description and tweak it below. Part-specific '
'descriptions appear first, then customer / coating / generic.',
)
line_description = fields.Text(
string='Line Description',
help='This text becomes the description of the sale order line. '
'Edit freely — your changes override the template.',
)
@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)
# ---- Computes ----
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
def _compute_totals(self):
for rec in self:
@@ -184,10 +119,10 @@ class FpDirectOrderWizard(models.TransientModel):
if has_missing else False
)
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Reset part selection when customer changes + pull invoice defaults + addresses."""
self.part_catalog_id = False
"""Seed invoice defaults + default addresses when customer changes."""
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
@@ -199,124 +134,23 @@ class FpDirectOrderWizard(models.TransientModel):
self.partner_invoice_id = False
self.partner_shipping_id = False
@api.onchange('description_template_id')
def _onchange_description_template(self):
"""Copy the template's text into the editable paragraph — user tweaks from there."""
if self.description_template_id:
self.line_description = self.description_template_id.description
@api.onchange('part_catalog_id', 'coating_config_id', 'partner_id')
def _onchange_suggest_template(self):
"""Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins):
1. This part's lowest-sequence active template
2. This customer's templates (no part)
3. This coating's templates (no part)
4. Don't auto-pick — user has to choose
"""
if self.description_template_id or self.line_description:
return # respect user's choice
Template = self.env['fp.sale.description.template']
# 1. Part-specific
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
# 2. Customer (no part)
if self.partner_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', self.partner_id.id),
], order='sequence', limit=1)
if match:
self.description_template_id = match.id
self.line_description = match.description
return
# 3. Coating (no part, no customer restriction)
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
return
@api.onchange('coating_config_id', 'quantity', 'partner_id')
def _onchange_lookup_price(self):
"""Auto-fill unit_price from customer price list when available."""
if not (self.partner_id and self.coating_config_id):
return
# Don't overwrite a manually-entered price
if self.unit_price:
return
price = self.env['fp.customer.price.list']._find_price(
self.partner_id.id, self.coating_config_id.id,
quantity=self.quantity or 1,
)
if price:
self.unit_price = price.unit_price
# ---- Actions ----
def action_create_order(self):
"""Create and confirm the sale order, optionally bumping part revision."""
"""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 before confirming.'))
if not self.po_attachment_file:
raise UserError(_('Upload the customer PO document.'))
if self.create_new_revision and not self.new_drawing_file:
raise UserError(_(
'Please upload the new drawing when creating a new revision.'
))
if self.quantity <= 0:
raise UserError(_('Quantity must be positive.'))
# 1. Optional: create a new part revision from the uploaded drawing
part = self.part_catalog_id
if self.create_new_revision:
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,
})
# action_create_revision returns an action dict; we keep the part
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,
})
# Attach drawing/model based on extension
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)]
part = new_rev
# 2. Save the PO attachment
# 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',
})
# 3. Find or create the generic plating service product (same as configurator)
# 2. Find or create the generic plating service product
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
@@ -330,53 +164,64 @@ class FpDirectOrderWizard(models.TransientModel):
'purchase_ok': False,
})
# Canonical line label (always present)
header = '%s%s Rev %s (x%d)' % (
self.coating_config_id.name,
part.name,
part.revision or part.revision_number,
self.quantity,
)
# Optional extended description from template / user tweak
extended = (self.line_description or '').strip()
if extended:
line_desc = '%s\n\n%s' % (header, extended)
else:
line_desc = header
# Bump template usage counter so popular ones float to the top over time
if self.description_template_id:
self.description_template_id._register_usage()
# 3. Build SO header
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'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,
'x_fc_delivery_method': self.delivery_method,
'origin': 'Direct Order',
'note': self.notes or False,
'order_line': [(0, 0, {
'order_line': [],
}
# 4. One SO line per wizard line
for line in self.line_ids:
part = line._get_or_bump_revision()
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': self.quantity,
'price_unit': self.unit_price or 0.0,
})],
}
'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,
}))
# 5. Create + confirm
so = self.env['sale.order'].create(so_vals)
# Immediately confirm — skips quote/send step entirely
so.action_confirm()
so.message_post(
body=_(
'Direct order created from PO %s. Quotation stage skipped.'
) % self.po_number,
)
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',

View File

@@ -6,11 +6,17 @@
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<div class="alert alert-warning mb-0"
role="alert"
invisible="not missing_info_msg">
<i class="fa fa-exclamation-triangle me-2"/>
<field name="missing_info_msg" readonly="1" nolabel="1"/>
</div>
<sheet>
<div class="oe_title">
<h1>New Direct Order</h1>
<p class="text-muted">
Skip the quotation stage create a confirmed order
Skip the quotation stage - create a confirmed order
when the customer has already sent a PO.
</p>
</div>
@@ -18,59 +24,30 @@
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
<field name="partner_invoice_id"
options="{'no_create_edit': True}"
invisible="not partner_id"/>
<field name="partner_shipping_id"
options="{'no_create_edit': True}"
invisible="not partner_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_file"
filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
</group>
</group>
<group string="Part">
<group>
<field name="part_catalog_id"
options="{'no_create_edit': True}"
context="{'default_partner_id': partner_id}"/>
<field name="part_number" invisible="not part_catalog_id"/>
<field name="current_revision" invisible="not part_catalog_id"/>
</group>
<group>
<label for="surface_area" invisible="not part_catalog_id"/>
<div class="o_row" invisible="not part_catalog_id">
<field name="surface_area" nolabel="1" class="oe_inline"/>
<field name="surface_area_uom" nolabel="1" class="oe_inline"/>
</div>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note" invisible="not create_new_revision"/>
</group>
<group>
<group string="Order">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="currency_id" invisible="1"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<group string="Scheduling">
<field name="planned_start_date"/>
<field name="internal_deadline"/>
<field name="customer_deadline"/>
</group>
<group string="Fulfilment">
<field name="rush_order"/>
<group string="Fulfilment &amp; Invoicing">
<field name="delivery_method"/>
</group>
</group>
<group string="Invoicing">
<group>
<field name="invoice_strategy"/>
<label for="deposit_percent"
invisible="invoice_strategy != 'deposit'"/>
@@ -89,19 +66,97 @@
</group>
</group>
<!-- ===== Line description — template picker + editable paragraph ===== -->
<group string="Line Description">
<field name="description_template_id"
options="{'no_create': True, 'no_open': True}"
placeholder="Start typing to search saved descriptions..."/>
<field name="line_description" nolabel="1" colspan="2"
placeholder="Pick a template above, then tweak the text here. Whatever you leave in this box lands on the sale order line."/>
</group>
<group string="Internal Notes">
<field name="notes" nolabel="1" colspan="2"
placeholder="Notes for the estimator / planner — not shown to the customer."/>
</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}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
options="{'no_create_edit': True}"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<field name="part_deadline"/>
<field name="rush_order" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
</list>
<form string="Order Line">
<group>
<group string="Part &amp; Treatment">
<field name="part_catalog_id"
context="{'default_partner_id': parent.partner_id}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"/>
<field name="part_number"
invisible="not part_catalog_id"/>
<field name="part_revision"
invisible="not part_catalog_id"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"/>
</group>
<group string="Qty &amp; Price">
<field name="quantity"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="part_deadline"/>
<field name="rush_order"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note"
invisible="not create_new_revision"/>
</group>
<group string="Line Description">
<field name="description_template_id"
options="{'no_create': True, 'no_open': True}"
placeholder="Start typing to search saved descriptions..."/>
<field name="line_description"
nolabel="1" colspan="2"
placeholder="Pick a template above, then tweak the text here."/>
</group>
</form>
</field>
<group class="mt-3">
<group>
<field name="total_line_count" readonly="1"/>
<field name="total_qty" readonly="1"/>
</group>
<group>
<field name="total_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"
placeholder="Internal notes for the estimator / planner - not shown to the customer."/>
</page>
</notebook>
</sheet>
<footer>