This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

@@ -6,4 +6,5 @@ from . import fp_direct_order_wizard
from . import fp_direct_order_line
from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard
from . import fp_quote_promote_wizard
from . import fp_part_catalog_import_wizard

View File

@@ -45,17 +45,7 @@ class FpAddFromQuoteWizard(models.TransientModel):
for q in self.quote_ids:
if not q.part_catalog_id or not q.coating_config_id:
continue
final = q.estimator_override_price or q.calculated_price
unit = (final / q.quantity) if (final and q.quantity) else 0.0
Line.create({
'wizard_id': wizard.id,
'part_catalog_id': q.part_catalog_id.id,
'coating_config_id': q.coating_config_id.id,
'quantity': int(q.quantity) or 1,
'unit_price': unit,
'quote_id': q.id,
'line_description': q.notes or False,
})
Line._create_from_quote(q, wizard)
copied += 1
if not copied:

View File

@@ -7,7 +7,8 @@ from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpDirectOrderLine(models.TransientModel):
class FpDirectOrderLine(models.Model):
"""Sub 9 — persistent so the parent draft survives navigation."""
_name = 'fp.direct.order.line'
_description = 'Fusion Plating - Direct Order Line'
_order = 'sequence, id'
@@ -59,38 +60,50 @@ class FpDirectOrderLine(models.TransientModel):
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# Sub 9 — explicit per-line process variant override. NULL means
# "use the part's default variant".
process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', part_catalog_id), "
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick a specific process variant for this line. Leave blank '
'to use the part\'s default variant. Manage variants via the '
'Process Composer on the part form.',
)
# Read-only preview of the process tree that WILL drive WO generation
# for this line. Resolution priority:
# 1. Part's composed process (fp.part.catalog.default_process_id)
# — a part-scoped customisation set via the Process Composer.
# 2. Primary Treatment's default recipe (fp.coating.config.recipe_id)
# — the shared template used if the part has no override.
# Shown so operators can see *what will run* before confirming the
# order. Treatment answers the "what coating"; process answers the
# "how" — they're distinct but coupled via the resolution chain.
# 1. Explicit process_variant_id (estimator pick)
# 2. Part's default variant (fp.part.catalog.default_process_id)
# 3. Primary Treatment's default recipe (fp.coating.config.recipe_id)
effective_process_id = fields.Many2one(
'fusion.plating.process.node',
string='Process',
compute='_compute_effective_process',
help='Process tree that will generate work orders for this line. '
'Uses the part-composed process if one exists, otherwise the '
"primary treatment's default recipe.",
help='Process tree that will generate work orders for this line.',
)
effective_process_source = fields.Char(
compute='_compute_effective_process',
help='Tells the estimator whether the process comes from the '
'part (customised) or the coating (shared default).',
help='Tells the estimator where the process comes from: '
'an explicit variant pick, the part default, or the coating default.',
)
@api.depends('part_catalog_id.default_process_id',
@api.depends('process_variant_id',
'part_catalog_id.default_process_id',
'coating_config_id.recipe_id')
def _compute_effective_process(self):
for rec in self:
if rec.process_variant_id:
rec.effective_process_id = rec.process_variant_id
label = rec.process_variant_id.variant_label or rec.process_variant_id.name
rec.effective_process_source = 'Variant: %s' % (label or 'unnamed')
continue
part_proc = (rec.part_catalog_id.default_process_id
if rec.part_catalog_id else False)
if part_proc:
rec.effective_process_id = part_proc
rec.effective_process_source = 'Part (customised)'
rec.effective_process_source = 'Part default'
continue
cc_proc = (rec.coating_config_id.recipe_id
if rec.coating_config_id else False)
@@ -101,6 +114,14 @@ class FpDirectOrderLine(models.TransientModel):
rec.effective_process_id = False
rec.effective_process_source = False
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped)."""
for rec in self:
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
# ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True)
currency_id = fields.Many2one(related='wizard_id.currency_id')
@@ -113,6 +134,19 @@ class FpDirectOrderLine(models.TransientModel):
currency_field='currency_id',
compute='_compute_line_subtotal',
)
# Sub 9 — taxes per line. Defaults from the FP-SERVICE product's
# sale taxes; fiscal-position-mapped from the customer when the
# wizard creates the SO line. Overridable per row.
tax_ids = fields.Many2many(
'account.tax',
relation='fp_direct_order_line_tax_rel',
column1='line_id',
column2='tax_id',
string='Taxes',
domain="[('type_tax_use', '=', 'sale')]",
help='Sales taxes applied to this line. Defaults from the plating '
'service product; override for tax-exempt or special-rate orders.',
)
# ---- Scheduling / fulfilment ----
part_deadline = fields.Date(
@@ -258,6 +292,27 @@ class FpDirectOrderLine(models.TransientModel):
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
# Seed default taxes from the FP-SERVICE product, fiscal-position
# mapped from the customer. Only fills when the user hasn't set
# taxes manually.
if not self.tax_ids:
self._seed_default_taxes()
def _seed_default_taxes(self):
"""Pick taxes from the FP-SERVICE product, mapped through the
customer's fiscal position when one is set."""
self.ensure_one()
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product or not product.taxes_id:
return
taxes = product.taxes_id
partner = self.wizard_id.partner_id
if partner and partner.property_account_position_id:
taxes = partner.property_account_position_id.map_tax(taxes)
if taxes:
self.tax_ids = [(6, 0, taxes.ids)]
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
def _onchange_lookup_price(self):
@@ -343,6 +398,30 @@ class FpDirectOrderLine(models.TransientModel):
_apply(match)
# ---- Helpers ----
@api.model
def _create_from_quote(self, quote, wizard):
"""Seed a Direct Order line from a `fp.quote.configurator` row.
Single source of truth for both the per-quote "Promote" action and
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
in one place so the two flows can never drift.
"""
if not quote.part_catalog_id or not quote.coating_config_id:
raise UserError(_(
'Quote %s has no part or coating set; cannot seed a line.'
) % (quote.name or quote.id))
final = quote.estimator_override_price or quote.calculated_price
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
return self.create({
'wizard_id': wizard.id,
'part_catalog_id': quote.part_catalog_id.id,
'coating_config_id': quote.coating_config_id.id,
'quantity': int(quote.quantity) or 1,
'unit_price': unit,
'quote_id': quote.id,
'line_description': quote.notes or False,
})
def _get_or_bump_revision(self):
"""Return the part to use for the SO line, optionally bumping revision."""
self.ensure_one()

View File

@@ -7,23 +7,60 @@ from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpDirectOrderWizard(models.TransientModel):
class FpDirectOrderWizard(models.Model):
"""Direct order entry for repeat customers.
Sub 9 — converted from TransientModel to persistent Model so an
estimator can save a draft, navigate elsewhere (part form, Process
Composer, customer record), and come back. Entries persist across
sessions; finished drafts move to state='confirmed' and link to the
sale.order they produced.
Creates a sale.order (in draft / quotation state) with one
sale.order.line per wizard line. The user reviews the resulting
quotation, makes any adjustments, and clicks Send / Confirm
manually. The wizard does NOT auto-confirm and does NOT auto-email
the customer — that was deliberately removed in Sub 1 after the
client requested a review step before anything leaves the shop.
the customer.
"""
_name = 'fp.direct.order.wizard'
_description = 'Fusion Plating - Direct Order Entry'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
state = fields.Selection(
[('draft', 'Draft'),
('confirmed', 'Confirmed'),
('cancelled', 'Cancelled')],
string='Status', default='draft', required=True, copy=False,
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
readonly=True, copy=False, tracking=True,
help='Set when the draft is confirmed — points to the SO created.',
)
user_id = fields.Many2one(
'res.users', string='Estimator',
default=lambda self: self.env.user, tracking=True,
)
# ---- Customer ----
# NB. Persistent model: partner is optional at draft-creation time so
# the estimator can spawn a blank draft and fill it in. The
# action_create_order method enforces the non-null check at confirm.
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
'res.partner', string='Customer',
domain="[('customer_rank', '>', 0)]",
tracking=True,
)
partner_invoice_id = fields.Many2one(
'res.partner', string='Invoice Address',
@@ -46,7 +83,7 @@ class FpDirectOrderWizard(models.TransientModel):
string='Planned Start', default=fields.Date.context_today,
)
internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline')
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
# ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean(
@@ -65,7 +102,7 @@ class FpDirectOrderWizard(models.TransientModel):
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
# underlying SO is confirmed with a chase activity scheduled for
# the expected date.
po_number = fields.Char(string='Customer PO #')
po_number = fields.Char(string='Customer PO #', tracking=True)
po_attachment_file = fields.Binary(string='PO Document')
po_attachment_filename = fields.Char(string='PO Filename')
po_pending = fields.Boolean(
@@ -101,6 +138,16 @@ class FpDirectOrderWizard(models.TransientModel):
progress_initial_percent = fields.Float(
string='Progress - Initial %', default=50.0,
)
# Sub 9 — payment terms surfaced on the wizard so the resulting SO
# picks them up. Auto-seeded from the customer's invoice-strategy
# default (or the partner's property_payment_term_id), then nudged
# again when the strategy changes (COD/Prepay → Immediate Payment).
# User can override per draft.
payment_term_id = fields.Many2one(
'account.payment.term', string='Payment Terms',
help='Carries onto the sale order. Auto-fills from the customer '
'invoice strategy default; COD / Prepay forces immediate payment.',
)
# ---- Notes ----
notes = fields.Text(string='Internal Notes')
@@ -121,6 +168,17 @@ class FpDirectOrderWizard(models.TransientModel):
# ---- Missing info banner ----
missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
# ---- Persistence helpers ----
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == _('New'):
vals['name'] = (
self.env['ir.sequence'].next_by_code('fp.direct.order.wizard')
or _('New Direct Order')
)
return super().create(vals_list)
# ---- Computes ----
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
def _compute_totals(self):
@@ -151,7 +209,7 @@ class FpDirectOrderWizard(models.TransientModel):
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Seed invoice defaults + default addresses when customer changes."""
"""Seed invoice defaults + addresses + payment terms 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
@@ -159,11 +217,93 @@ class FpDirectOrderWizard(models.TransientModel):
addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
# Seed payment terms: customer's invoice-strategy default wins;
# fallback to partner.property_payment_term_id.
term = False
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if isd and isd.payment_term_id:
term = isd.payment_term_id
# Also seed strategy from the same record if not already set.
if not self.invoice_strategy:
self.invoice_strategy = isd.default_strategy
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
self.payment_term_id = term or False
else:
self.partner_invoice_id = False
self.partner_shipping_id = False
self.payment_term_id = False
# Re-apply strategy → terms mapping after partner switch.
self._apply_strategy_payment_term()
@api.onchange('invoice_strategy')
def _onchange_invoice_strategy(self):
"""Map the strategy onto sensible payment terms."""
self._apply_strategy_payment_term()
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment (Odoo's stock term)
- deposit / progress / net_terms → keep what the partner default
already gave us; if blank, leave it blank so the user can pick.
Never overwrites an explicit user choice for non-COD strategies —
only fills in when payment_term_id is empty.
"""
for rec in self:
if rec.invoice_strategy == 'cod_prepay':
immediate = rec.env.ref(
'account.account_payment_term_immediate',
raise_if_not_found=False,
)
if immediate:
rec.payment_term_id = immediate.id
# ---- Actions ----
@api.model
def action_open_new_draft(self):
"""Create a fresh draft record and open it in form view.
Wired to the "New Direct Order" menu / button. Creating the
record up front means the draft is auto-persisted from the
first keystroke — the estimator can navigate away (to the
part form, the Process Composer, etc.) without losing work.
"""
draft = self.create({})
return {
'type': 'ir.actions.act_window',
'name': _('Direct Order'),
'res_model': 'fp.direct.order.wizard',
'res_id': draft.id,
'view_mode': 'form',
'target': 'current',
}
def action_cancel(self):
"""Move the draft to cancelled state. Kept for audit; not deleted."""
self.write({'state': 'cancelled'})
return True
def action_reopen(self):
"""Reopen a cancelled draft for further editing."""
self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})
return True
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_add_from_prior_so(self):
"""Open a sub-wizard to copy lines from a prior sale.order."""
self.ensure_one()
@@ -207,6 +347,8 @@ class FpDirectOrderWizard(models.TransientModel):
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
"""
self.ensure_one()
if not self.partner_id:
raise UserError(_('Pick a customer before confirming.'))
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
@@ -269,6 +411,7 @@ class FpDirectOrderWizard(models.TransientModel):
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
'x_fc_progress_initial_percent': self.progress_initial_percent,
'payment_term_id': self.payment_term_id.id or False,
'x_fc_delivery_method': self.delivery_method,
'x_fc_is_blanket_order': self.is_blanket_order,
'x_fc_block_partial_shipments': self.block_partial_shipments,
@@ -312,11 +455,18 @@ class FpDirectOrderWizard(models.TransientModel):
'x_fc_start_at_node_id': line.start_at_node_id.id or False,
'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False,
'x_fc_process_variant_id': line.process_variant_id.id or False,
# Sub 5 — carry serial / job# / thickness onto the SO line.
# Revision snapshot auto-fills on SO-line create from the part.
'x_fc_serial_id': line.serial_id.id or False,
'x_fc_job_number': line.job_number or False,
'x_fc_thickness_id': line.thickness_id.id or False,
# Sub 9 — explicit tax override from the wizard line.
# When blank, Odoo will compute taxes from the product
# defaults at SO-line save time (the standard behaviour).
# NB. Odoo 19 renamed the SO line field to tax_ids.
'tax_ids': ([(6, 0, line.tax_ids.ids)]
if line.tax_ids else False),
}))
# 5. Create — stays in draft / quotation. Sub 1: user reviews
@@ -324,6 +474,27 @@ class FpDirectOrderWizard(models.TransientModel):
# auto-email to the client.
so = self.env['sale.order'].create(so_vals)
# Mark this draft as confirmed and link the SO.
self.write({'state': 'confirmed', 'sale_order_id': so.id})
# Sub 10 — flip every linked quote to "won" now that an SO exists.
# We deliberately wait until SO creation rather than at promote
# time, because "won" should mean "the deal closed", not "we put
# it on a draft." A draft can still be cancelled.
linked_quotes = self.line_ids.mapped('quote_id').filtered(
lambda q: q.state in ('draft', 'sent', 'accepted')
)
if linked_quotes:
linked_quotes.write({
'state': 'confirmed',
'won_date': fields.Date.today(),
'sale_order_id': so.id,
})
for q in linked_quotes:
q.message_post(body=_(
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
) % {'doo': self.name, 'so': so.name})
# 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one.

View File

@@ -6,12 +6,32 @@
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<header>
<button name="action_create_order" type="object"
string="Create &amp; Confirm Order"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_view_sale_order" type="object"
string="Open Sale Order"
class="btn-primary"
invisible="state != 'confirmed' or not sale_order_id"/>
<button name="action_cancel" type="object"
string="Discard Draft"
confirm="Mark this draft as cancelled? The data is preserved for audit."
invisible="state != 'draft'"/>
<button name="action_reopen" type="object"
string="Reopen Draft"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed"/>
</header>
<div class="alert alert-info py-2 mb-0 small"
role="alert">
role="alert"
invisible="state != 'draft'">
<i class="fa fa-info-circle me-1"/>
Changes are not saved until you click
<strong>Create &amp; Confirm Order</strong>. Closing this
window (Esc or X) discards your entries.
This draft is auto-saved as you edit. You can navigate away
(open the part form, the Process Composer, etc.) and return
via <strong>Sales → Direct Order Drafts</strong>.
</div>
<div class="alert alert-warning mb-0"
role="alert"
@@ -20,11 +40,23 @@
<field name="missing_info_msg" readonly="1" nolabel="1"/>
</div>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
</div>
<div class="oe_title">
<h1>New Direct Order</h1>
<p class="text-muted">
Skip the quotation stage - create a confirmed order
when the customer has already sent a PO.
<label for="name" class="o_form_label"/>
<h1><field name="name" readonly="1"/></h1>
<field name="user_id" readonly="state != 'draft'"
options="{'no_create': True}"/>
<p class="text-muted" invisible="state != 'draft'">
Skip the quotation stage — create a confirmed order
when the customer has already sent a PO. Drafts auto-save.
</p>
</div>
@@ -70,6 +102,8 @@
<group string="Fulfilment &amp; Invoicing">
<field name="delivery_method"/>
<field name="invoice_strategy"/>
<field name="payment_term_id"
options="{'no_create': True}"/>
<label for="deposit_percent"
invisible="invoice_strategy != 'deposit'"/>
<div class="o_row"
@@ -112,12 +146,20 @@
options="{'no_create_edit': True}"/>
<field name="description_template_id"
domain="[('part_catalog_id', '=', part_catalog_id)]"
options="{'no_create': True}"
context="{'default_part_catalog_id': part_catalog_id}"
invisible="not part_catalog_id"
optional="hide"/>
<field name="line_description"
string="Customer-Facing"
optional="hide"/>
<field name="internal_description"
optional="hide"/>
<field name="coating_config_id"/>
<field name="process_variant_id"
string="Variant"
options="{'no_create': True}"
invisible="not part_catalog_id"
optional="show"/>
<field name="effective_process_id"
string="Process"
readonly="1"
@@ -141,6 +183,10 @@
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="tax_ids"
widget="many2many_tags"
options="{'no_create': True}"
optional="show"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"
@@ -163,6 +209,10 @@
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"/>
<field name="process_variant_id"
string="Process Variant"
options="{'no_create': True}"
invisible="not part_catalog_id"/>
<field name="effective_process_id"
string="Effective Process"
readonly="1"/>
@@ -178,6 +228,9 @@
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="tax_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
<field name="line_subtotal"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
@@ -199,8 +252,9 @@
</group>
<group string="Line Description">
<field name="description_template_id"
options="{'no_create': True, 'no_open': True}"
placeholder="Start typing to search saved descriptions..."/>
domain="[('part_catalog_id', '=', part_catalog_id)]"
context="{'default_part_catalog_id': part_catalog_id}"
placeholder="Start typing to search saved descriptions, or type a new name to create one..."/>
<label for="line_description"
string="Customer-Facing"/>
<field name="line_description"
@@ -245,29 +299,100 @@
</notebook>
</sheet>
<footer>
<button name="action_create_order"
type="object"
string="Create &amp; Confirm Order"
class="btn-primary"/>
<button string="Cancel"
special="cancel"
class="btn-secondary"
confirm="Discard this order? All header data and line items will be lost."/>
</footer>
<chatter/>
</form>
</field>
</record>
<!-- Form action — keeps the same external ID as before so existing
button references survive (act_window cannot be replaced by a
server action with the same xmlid). target='current' lets the
estimator breadcrumb between the wizard and the part form / Composer.
Odoo prompts to save unsaved changes when navigating away. -->
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
<field name="name">New Direct Order</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<!-- Use Odoo's built-in extra-large dialog size so the line
table (10+ columns) isn't squeezed into ellipsis at the
default modal width. Roughly 30% wider than the default. -->
<field name="context">{'dialog_size': 'extra-large'}</field>
<field name="target">current</field>
<field name="context">{}</field>
</record>
<!-- ===== Drafts list view (resume an in-flight order entry) ===== -->
<record id="view_fp_direct_order_wizard_list" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.list</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<list string="Direct Order Drafts"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancelled'"
decoration-success="state == 'confirmed'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id"/>
<field name="po_number" optional="show"/>
<field name="customer_deadline" optional="hide"/>
<field name="total_line_count" optional="hide"/>
<field name="total_qty" optional="hide"/>
<field name="total_amount" widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<field name="currency_id" column_invisible="1"/>
<field name="create_date" optional="show"/>
<field name="sale_order_id" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'confirmed'"
decoration-muted="state == 'cancelled'"/>
</list>
</field>
</record>
<record id="view_fp_direct_order_wizard_search" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.search</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="po_number"/>
<field name="user_id"/>
<filter name="filter_draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_confirmed" string="Confirmed"
domain="[('state', '=', 'confirmed')]"/>
<filter name="filter_cancelled" string="Cancelled"
domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter name="filter_my" string="My Drafts"
domain="[('user_id', '=', uid)]"/>
<group>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_partner" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_user" string="Estimator"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_direct_order_drafts" model="ir.actions.act_window">
<field name="name">Direct Order Drafts</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
<field name="search_view_id" ref="view_fp_direct_order_wizard_search"/>
<field name="context">{'search_default_filter_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No drafts yet — start one!
</p>
<p>
Drafts persist across sessions. Save your progress, switch to a
part form, edit the Process Composer, and come back to finish.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpQuotePromoteWizard(models.TransientModel):
"""Chooser dialog: promote a won quote into a Direct Order draft.
Sub 10 — quote→direct-order handoff. The estimator picks either an
existing open draft for this customer (lets multiple quotes
consolidate onto a single PO) or creates a fresh draft.
"""
_name = 'fp.quote.promote.wizard'
_description = 'Promote Quote to Direct Order'
quote_id = fields.Many2one(
'fp.quote.configurator', required=True, readonly=True,
)
partner_id = fields.Many2one(
related='quote_id.partner_id', readonly=True,
)
quote_currency_id = fields.Many2one(
related='quote_id.currency_id', readonly=True,
)
target_mode = fields.Selection(
[('existing', 'Add to existing draft'),
('new', 'Create new Direct Order')],
string='Target', required=True, default='new',
)
target_wizard_id = fields.Many2one(
'fp.direct.order.wizard',
string='Existing Draft',
domain="[('state', '=', 'draft'), ('partner_id', '=', partner_id)]",
help='Pick an open draft for this customer. The quote is added '
'as a new line to that draft.',
)
open_drafts_count = fields.Integer(
compute='_compute_open_drafts_count',
help='Drafts currently open for this customer.',
)
@api.depends('partner_id')
def _compute_open_drafts_count(self):
DOO = self.env['fp.direct.order.wizard']
for rec in self:
rec.open_drafts_count = DOO.search_count([
('state', '=', 'draft'),
('partner_id', '=', rec.partner_id.id),
]) if rec.partner_id else 0
@api.onchange('partner_id', 'open_drafts_count')
def _onchange_default_target(self):
for rec in self:
if rec.open_drafts_count == 0:
rec.target_mode = 'new'
def action_promote(self):
self.ensure_one()
q = self.quote_id
# Re-check the not-already-promoted invariant — a separate user
# could have added this quote to a draft between the action open
# and the click, so we re-verify before mutating.
existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', q.id),
('wizard_id.state', '=', 'draft'),
], limit=1)
if existing_line:
raise UserError(_(
'This quote is already on draft "%s". Open that draft '
'and remove its line if you want to move it elsewhere.'
) % existing_line.wizard_id.name)
# Resolve target draft.
if self.target_mode == 'existing':
if not self.target_wizard_id:
raise UserError(_('Pick an existing draft, or switch to '
'"Create new Direct Order".'))
target = self.target_wizard_id
if target.state != 'draft':
raise UserError(_(
'Draft "%s" is no longer in draft state.'
) % target.name)
else:
target = self.env['fp.direct.order.wizard'].create({
'partner_id': q.partner_id.id,
'currency_id': q.currency_id.id,
})
# Currency must match — Direct Order doesn't convert.
if target.currency_id != q.currency_id:
raise UserError(_(
'Quote currency (%s) does not match Direct Order '
'currency (%s). Re-quote in the order currency, or '
'create a new Direct Order in this quote\'s currency.'
) % (q.currency_id.name, target.currency_id.name))
# Seed the line.
self.env['fp.direct.order.line']._create_from_quote(q, target)
# Open the target draft so the estimator can keep adding lines.
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': target.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_quote_promote_wizard_form" model="ir.ui.view">
<field name="name">fp.quote.promote.wizard.form</field>
<field name="model">fp.quote.promote.wizard</field>
<field name="arch" type="xml">
<form string="Promote Quote to Direct Order">
<sheet>
<div class="oe_title">
<h2>Add Quote to Direct Order</h2>
<p class="text-muted">
This quote will be added as a single line on a
Direct Order draft. Multiple quotes can land on
the same draft so one PO covers them all.
</p>
</div>
<group>
<field name="quote_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="quote_currency_id" readonly="1"/>
<field name="open_drafts_count" readonly="1"/>
</group>
<group>
<field name="target_mode" widget="radio"/>
<field name="target_wizard_id"
options="{'no_create': True}"
required="target_mode == 'existing'"
invisible="target_mode != 'existing'"/>
</group>
<div class="alert alert-info py-2 mb-0 small"
role="alert"
invisible="open_drafts_count != 0 or target_mode != 'new'">
<i class="fa fa-info-circle me-1"/>
No open drafts for this customer — a fresh Direct
Order will be created.
</div>
</sheet>
<footer>
<button name="action_promote" type="object"
string="Add to Direct Order"
class="btn-primary"/>
<button string="Cancel" special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>