changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user