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

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.14.2.0',
'version': '19.0.17.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -49,12 +49,13 @@ Provides:
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/res_partner_views.xml',
'views/fp_configurator_menu.xml',
'views/fp_sale_description_template_views.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml',
'wizard/fp_quote_promote_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml',
],
'assets': {

View File

@@ -37,6 +37,28 @@ _CLONABLE_FIELDS = (
)
def _list_variants(part):
"""Return a list of {id, label, is_default, node_count} for a part's variants."""
Node = part.env['fusion.plating.process.node']
variants = part.process_variant_ids.sorted(
lambda v: (not v.is_default_variant, v.variant_label or v.name or '')
)
out = []
for v in variants:
node_count = Node.search_count([
('part_catalog_id', '=', part.id),
('id', 'child_of', v.id),
])
out.append({
'id': v.id,
'label': v.variant_label or v.name or '(unnamed)',
'name': v.name or '',
'is_default': bool(v.is_default_variant),
'node_count': node_count,
})
return out
def _clone_subtree(env, source, part, parent):
"""Recursively clone a process node subtree for a specific part.
@@ -117,7 +139,7 @@ class FpPartComposerController(http.Controller):
# ------------------------------------------------------------------
@http.route('/fp/part/composer/state', type='jsonrpc', auth='user')
def state(self, part_id):
"""Return part info plus the current default_process_id tree (or None)."""
"""Return part info, current default tree, and full variant list."""
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
@@ -134,6 +156,7 @@ class FpPartComposerController(http.Controller):
},
'has_tree': bool(root),
'root_id': root.id if root else False,
'variants': _list_variants(part),
}
# ------------------------------------------------------------------
@@ -157,18 +180,20 @@ class FpPartComposerController(http.Controller):
}
# ------------------------------------------------------------------
# Write — clone a template into the part
# Write — create a new variant by cloning a template OR another variant
# ------------------------------------------------------------------
@http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user')
def load_template(self, part_id, template_id):
"""Clone a shared template into a part-scoped tree.
def load_template(self, part_id, template_id, variant_label=None,
make_default=None):
"""Clone a shared template into a NEW variant on this part.
Deletes any existing part-owned tree for this part first, then
deep-clones the template subtree with part ownership set. Finally
pins ``part.default_process_id`` to the new root.
Unlike the previous behaviour (wipe & replace), this now adds a
variant alongside any existing ones. The first variant created
becomes the default; subsequent variants only become default if
``make_default`` is true.
The whole operation runs inside a savepoint — if anything fails
partway through, the part is left in its previous state.
If ``variant_label`` is omitted, the controller uses the
template's name as the label.
"""
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists()
@@ -181,38 +206,119 @@ class FpPartComposerController(http.Controller):
if tpl.node_type != 'recipe':
return {'ok': False, 'error': 'Template must be a recipe-type node'}
label = (variant_label or tpl.name or 'Variant').strip()
try:
with request.env.cr.savepoint():
# 1. Delete any prior part-owned tree for this part.
# parent_id has ondelete='cascade', so deleting root(s)
# wipes their descendants. Use search so we don't assume
# only default_process_id's tree exists.
prior = request.env['fusion.plating.process.node'].search([
('part_catalog_id', '=', part.id),
])
if prior:
prior.unlink()
# First variant on this part is always the default.
is_first = not part.process_variant_ids
make_default_flag = bool(make_default) or is_first
# 2. Deep-clone the template subtree with part ownership.
new_root = _clone_subtree(request.env, tpl, part, parent=False)
new_root.variant_label = label
new_root.is_default_variant = make_default_flag
# 3. Pin part.default_process_id to the new root.
part.default_process_id = new_root.id
if make_default_flag:
# Clear flag from any other variants and pin default_process_id.
others = part.process_variant_ids.filtered(
lambda v: v.id != new_root.id and v.is_default_variant
)
if others:
others.write({'is_default_variant': False})
part.default_process_id = new_root.id
node_count = request.env['fusion.plating.process.node'].search_count([
('part_catalog_id', '=', part.id),
('id', 'child_of', new_root.id),
])
_logger.info(
'Part Composer: cloned template %s (%s) → part %s (%s), %s nodes, by uid %s',
tpl.id, tpl.name, part.id, part.display_name,
node_count, request.env.uid,
'Part Composer: variant "%s" cloned from template %s onto part %s (default=%s, %s nodes), uid %s',
label, tpl.id, part.id, make_default_flag, node_count, request.env.uid,
)
return {
'ok': True,
'root_id': new_root.id,
'node_count': node_count,
'variants': _list_variants(part),
}
except Exception as exc:
_logger.exception('Part Composer load_template failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Variant CRUD
# ------------------------------------------------------------------
@http.route('/fp/part/composer/duplicate_variant', type='jsonrpc', auth='user')
def duplicate_variant(self, part_id, source_variant_id, variant_label=None):
"""Deep-copy an existing variant into a new variant on the same part."""
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
src = request.env['fusion.plating.process.node'].browse(int(source_variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not src or src.part_catalog_id.id != part.id or src.parent_id:
return {'ok': False, 'error': 'Invalid source variant'}
label = (variant_label or ((src.variant_label or src.name or 'Variant') + ' (copy)')).strip()
try:
with request.env.cr.savepoint():
new_root = _clone_subtree(request.env, src, part, parent=False)
new_root.variant_label = label
new_root.is_default_variant = False # never auto-default a duplicate
node_count = request.env['fusion.plating.process.node'].search_count([
('id', 'child_of', new_root.id),
])
return {
'ok': True,
'root_id': new_root.id,
'node_count': node_count,
'variants': _list_variants(part),
}
except Exception as exc:
_logger.exception('Part Composer duplicate_variant failed')
return {'ok': False, 'error': str(exc)}
@http.route('/fp/part/composer/rename_variant', type='jsonrpc', auth='user')
def rename_variant(self, part_id, variant_id, variant_label):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not v or v.part_catalog_id.id != part.id or v.parent_id:
return {'ok': False, 'error': 'Invalid variant'}
label = (variant_label or '').strip()
if not label:
return {'ok': False, 'error': 'Label cannot be empty'}
v.variant_label = label
return {'ok': True, 'variants': _list_variants(part)}
@http.route('/fp/part/composer/set_default_variant', type='jsonrpc', auth='user')
def set_default_variant(self, part_id, variant_id):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
ok = part.action_set_default_variant(int(variant_id))
if not ok:
return {'ok': False, 'error': 'Variant does not belong to this part'}
return {'ok': True, 'variants': _list_variants(part)}
@http.route('/fp/part/composer/delete_variant', type='jsonrpc', auth='user')
def delete_variant(self, part_id, variant_id):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not v or v.part_catalog_id.id != part.id or v.parent_id:
return {'ok': False, 'error': 'Invalid variant'}
if v.is_default_variant and len(part.process_variant_ids) > 1:
return {'ok': False,
'error': 'Cannot delete the default variant. Set another variant as default first.'}
try:
with request.env.cr.savepoint():
if part.default_process_id.id == v.id:
part.default_process_id = False
# ondelete=cascade on parent_id wipes descendants.
v.unlink()
return {'ok': True, 'variants': _list_variants(part)}
except Exception as exc:
_logger.exception('Part Composer delete_variant failed')
return {'ok': False, 'error': str(exc)}

View File

@@ -14,4 +14,12 @@
<field name="company_id" eval="False"/>
</record>
<record id="seq_fp_direct_order_wizard" model="ir.sequence">
<field name="name">Fusion Plating: Direct Order Draft</field>
<field name="code">fp.direct.order.wizard</field>
<field name="prefix">DOD-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
# Sub 9 — Process Variants per Part. Runs on upgrade to 19.0.15.0.0.
#
# For every part that had a default_process_id, mark its root node as
# the default variant and seed a friendly label. Idempotent (NULL guards).
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
_logger.info("Sub 9: backfilling process variant flags")
# Step 1: Mark each part's existing default_process_id root as the
# default variant. The flag is cleared on every other root so we
# land in a consistent "exactly one default" state.
cr.execute("""
UPDATE fusion_plating_process_node
SET is_default_variant = FALSE
WHERE parent_id IS NULL
AND node_type = 'recipe'
AND part_catalog_id IS NOT NULL
""")
_logger.info("Sub 9: cleared is_default_variant on %d roots", cr.rowcount)
cr.execute("""
UPDATE fusion_plating_process_node node
SET is_default_variant = TRUE
FROM fp_part_catalog part
WHERE part.default_process_id = node.id
AND node.parent_id IS NULL
AND node.node_type = 'recipe'
AND node.part_catalog_id = part.id
""")
_logger.info(
"Sub 9: flagged is_default_variant on %d roots (one per part with default_process_id)",
cr.rowcount,
)
# Step 2: Seed variant_label='Default' on the now-flagged variants
# so the picker shows something readable. Only fills NULL/empty.
cr.execute("""
UPDATE fusion_plating_process_node
SET variant_label = 'Default'
WHERE is_default_variant = TRUE
AND (variant_label IS NULL OR variant_label = '')
""")
_logger.info("Sub 9: seeded variant_label='Default' on %d records", cr.rowcount)
_logger.info("Sub 9: migration complete")

View File

@@ -154,14 +154,36 @@ class FpPartCatalog(models.Model):
# Sub 3 — part's cloned process tree. NULL until the user first
# composes a process. The Composer client action sets this to the
# root node of the cloned tree.
#
# Sub 9 — multiple variants per part. `default_process_id` now points
# to "the variant flagged is_default_variant". `process_variant_ids`
# is the full set; estimators pick one per order line.
default_process_id = fields.Many2one(
'fusion.plating.process.node',
string='Default Process',
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe')]",
help='Root of this part\'s composed process tree. Use the '
'Compose button to edit. When a job runs for this part, '
'work orders are generated from this tree.',
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe'), "
"('parent_id', '=', False)]",
help='Root of this part\'s default process variant. Use the '
'Compose button to edit. When an order does not pick a '
'specific variant, this one is used.',
)
process_variant_ids = fields.One2many(
'fusion.plating.process.node',
'part_catalog_id',
string='Process Variants',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
help='All recipe variants composed for this part. Each order line '
'picks one (or falls back to the default).',
)
process_variant_count = fields.Integer(
string='Variants',
compute='_compute_process_variant_count',
)
@api.depends('process_variant_ids')
def _compute_process_variant_count(self):
for rec in self:
rec.process_variant_count = len(rec.process_variant_ids)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
@@ -404,6 +426,28 @@ class FpPartCatalog(models.Model):
'target': 'current',
}
def action_set_default_variant(self, variant_id):
"""Flip the default variant for this part.
Clears the flag from any other variant and pins
`default_process_id` to the chosen one. Called by the Composer
when the estimator switches default in the variant picker.
"""
self.ensure_one()
Node = self.env['fusion.plating.process.node']
new_default = Node.browse(int(variant_id)).exists()
if not new_default or new_default.part_catalog_id.id != self.id:
return False
# Clear flag on any other variant; set on the new one.
siblings = self.process_variant_ids.filtered(
lambda v: v.id != new_default.id and v.is_default_variant
)
if siblings:
siblings.write({'is_default_variant': False})
new_default.is_default_variant = True
self.default_process_id = new_default.id
return True
def action_view_customer(self):
self.ensure_one()
return {

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
class FpPricingComplexitySurcharge(models.Model):
@@ -19,6 +19,15 @@ class FpPricingComplexitySurcharge(models.Model):
)
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
@api.depends('complexity', 'surcharge_percent')
def _compute_display_name(self):
labels = dict(self._fields['complexity'].selection)
for rec in self:
label = labels.get(rec.complexity, rec.complexity or '')
if rec.surcharge_percent:
label = '%s +%g%%' % (label, rec.surcharge_percent)
rec.display_name = label or 'Surcharge'
_sql_constraints = [
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
'Only one surcharge per complexity level per rule.'),

View File

@@ -52,3 +52,24 @@ class FpProcessNode(models.Model):
'Picks which physical property of the part to multiply by '
'the per-unit rate: weight (Lbs) or surface area (Sq in).',
)
# ---- Process Variants (per-part) ----------------------------------------
# A part can carry multiple recipe-root trees ("variants"). Examples:
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
# variant; the MO walker resolves through it. One variant per part is the
# default — used when the order line doesn't pick one explicitly.
#
# Variant identification only applies to root nodes (parent_id IS NULL,
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
# these fields too because they sit on the same model, but they're only
# meaningful on roots.
is_default_variant = fields.Boolean(
string='Default Variant',
help='When ticked, this variant is used by default for new orders '
'of this part. Exactly one variant per part is the default.',
)
variant_label = fields.Char(
string='Variant Label',
help='Friendly label shown in the variant picker '
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
)

View File

@@ -510,8 +510,53 @@ class FpQuoteConfigurator(models.Model):
'fp.quote.configurator') or 'New'
return super().create(vals_list)
def action_promote_to_direct_order(self):
"""Sub 10 — push this quote onto a Direct Order draft.
Replaces the legacy 1-line-SO creation. The estimator picks an
existing draft for the customer (consolidating multiple quotes
onto one PO) or spawns a fresh draft. The quote stays in
`draft` state until the Direct Order is confirmed; that confirm
flips the quote to `won` and back-links the SO.
"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft quotes can be promoted.'))
if self.sale_order_id:
raise UserError(_(
'A sale order has already been created for this quote.'
))
if not self.part_catalog_id:
raise UserError(_(
'Pick a part catalog entry before promoting this quote.'
))
if not self.coating_config_id:
raise UserError(_(
'Pick a coating configuration before promoting this quote.'
))
existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', self.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)
return {
'type': 'ir.actions.act_window',
'name': _('Add Quote to Direct Order'),
'res_model': 'fp.quote.promote.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_quote_id': self.id},
}
def action_create_quotation(self):
"""Create a sale.order from this configurator session."""
"""LEGACY (Sub 10): kept for backwards-compat with any in-flight
records or external triggers. New flow is via
action_promote_to_direct_order.
"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft configurators can create quotations.'))

View File

@@ -139,11 +139,45 @@ class SaleOrder(models.Model):
'margin fields should render "n/a" in the UI.',
)
x_fc_workorder_count = fields.Integer(
string='Active WOs',
compute='_compute_workorder_count',
# NB. The compute lives in fusion_plating_bridge_mrp. We keep a
# stub field here so configurator's SO view (loaded before
# bridge_mrp on `-u`) can reference the field by name. bridge_mrp's
# `fields.Integer(compute=…)` redeclaration fills in the compute on
# top of this stub during its own load pass.
x_fc_workorder_count = fields.Integer(string='Work Orders')
# Sub 9 — process variant summary across order lines. Renders one
# variant label when all lines share one, otherwise "Mixed (N)".
x_fc_process_summary = fields.Char(
string='Process',
compute='_compute_process_summary',
help='Process variant(s) used by this order. Drives WO generation.',
)
@api.depends(
'order_line.x_fc_process_variant_id',
'order_line.x_fc_part_catalog_id.default_process_id',
)
def _compute_process_summary(self):
for so in self:
variants = []
for line in so.order_line:
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id):
continue # non-plating line
variant = (line.x_fc_process_variant_id
or line.x_fc_part_catalog_id.default_process_id)
if variant and variant not in variants:
variants.append(variant)
if not variants:
so.x_fc_process_summary = False
elif len(variants) == 1:
v = variants[0]
so.x_fc_process_summary = (
v.variant_label or v.name or 'Default'
)
else:
so.x_fc_process_summary = 'Mixed (%d variants)' % len(variants)
# ---- Phase E: list view helpers ----
x_fc_wo_completion = fields.Char(
string='WO Progress',
@@ -307,34 +341,6 @@ class SaleOrder(models.Model):
- sum(refunds.mapped('amount_total'))
)
@api.depends('name')
def _compute_workorder_count(self):
for rec in self:
rec.x_fc_workorder_count = 0
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names),
('state', 'not in', ('done', 'cancel'))],
['production_id'],
['production_id'],
lazy=False,
)
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if origin:
totals[origin] = totals.get(origin, 0) + r['__count']
for rec in self:
rec.x_fc_workorder_count = totals.get(rec.name, 0)
def action_view_workorders(self):
self.ensure_one()
return {

View File

@@ -60,6 +60,19 @@ class SaleOrderLine(models.Model):
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
# Sub 9 — process variant override per line. NULL means "use the
# part's default variant". Domain restricts to root recipe nodes
# owned by the chosen part.
x_fc_process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', x_fc_part_catalog_id), "
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick a specific process variant for this order. Leave blank '
'to use the part\'s default variant. Variants are managed via '
'the Process Composer on the part form.',
)
x_fc_archived = fields.Boolean(
string='Archived',
default=False,
@@ -226,6 +239,16 @@ class SaleOrderLine(models.Model):
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
return vals
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_variant(self):
"""Clear process variant when the part changes — domain would
otherwise leave a stale value pointing at the wrong part."""
for line in self:
if (line.x_fc_process_variant_id
and line.x_fc_process_variant_id.part_catalog_id
!= line.x_fc_part_catalog_id):
line.x_fc_process_variant_id = False
@api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self):
"""Clear the thickness picker when coating config changes.

View File

@@ -25,6 +25,8 @@ access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_fp_add_from_so_wizard_manager fp.add.from.so.wizard.manager model_fp_add_from_so_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
26 access_fp_add_from_quote_wizard_estimator fp.add.from.quote.wizard.estimator model_fp_add_from_quote_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
27 access_fp_add_from_quote_wizard_manager fp.add.from.quote.wizard.manager model_fp_add_from_quote_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
28 access_fp_quote_promote_wizard_estimator fp.quote.promote.wizard.estimator model_fp_quote_promote_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
29 access_fp_quote_promote_wizard_manager fp.quote.promote.wizard.manager model_fp_quote_promote_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
30 access_fp_sale_assembly_user fp.sale.assembly.user model_fp_sale_assembly base.group_user 1 0 0 0
31 access_fp_sale_assembly_estimator fp.sale.assembly.estimator model_fp_sale_assembly fusion_plating_configurator.group_fp_estimator 1 1 1 1
32 access_fp_sale_assembly_manager fp.sale.assembly.manager model_fp_sale_assembly fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -4,9 +4,9 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Thin wrapper around the existing recipe tree editor. Gives a part
// its own composed process tree by cloning a shared template, then
// hands off to the fp_recipe_tree_editor action for edits.
// Sub 9 — multi-variant Composer. Each part can carry several recipe trees
// (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
// estimators may pick a non-default variant on a per-order basis.
//
// Odoo 19 conventions:
// * Backend OWL: static template + static props = ["*"]
@@ -27,8 +27,6 @@ export class FpPartProcessComposer extends Component {
this.action = useService("action");
this.notification = useService("notification");
// Pull part_id out of the client action's params (set by
// fp.part.catalog.action_open_part_composer on the server).
const params = (this.props.action && this.props.action.params) || {};
this.partId = params.part_id || null;
@@ -38,9 +36,11 @@ export class FpPartProcessComposer extends Component {
part: null,
hasTree: false,
rootId: null,
variants: [],
templates: [],
selectedTemplateId: null,
loadingTemplate: false,
newVariantLabel: "",
busy: false,
});
onMounted(() => this.refresh());
@@ -67,10 +67,9 @@ export class FpPartProcessComposer extends Component {
this.state.part = stateRes.part;
this.state.hasTree = stateRes.has_tree;
this.state.rootId = stateRes.root_id || null;
this.state.variants = stateRes.variants || [];
this.state.templates = tplRes.templates || [];
// Default the dropdown selection to the first template so the
// user can click Load immediately.
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
this.state.selectedTemplateId = this.state.templates[0].id;
}
@@ -87,46 +86,110 @@ export class FpPartProcessComposer extends Component {
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
}
async onLoadTemplate() {
if (!this.state.selectedTemplateId) return;
const confirmReplace = this.state.hasTree
? window.confirm("This will replace the current process tree for this part. Continue?")
: true;
if (!confirmReplace) return;
onNewLabelInput(ev) {
this.state.newVariantLabel = ev.target.value || "";
}
this.state.loadingTemplate = true;
try {
async onAddVariantFromTemplate() {
if (!this.state.selectedTemplateId) {
this.notification.add("Pick a template first.", { type: "warning" });
return;
}
const label = (this.state.newVariantLabel || "").trim()
|| (this.state.templates.find(t => t.id === this.state.selectedTemplateId)?.name)
|| "Variant";
await this._busy(async () => {
const res = await rpc("/fp/part/composer/load_template", {
part_id: this.partId,
template_id: this.state.selectedTemplateId,
variant_label: label,
});
if (!res.ok) throw new Error(res.error || "Load failed.");
if (!res.ok) throw new Error(res.error || "Add variant failed.");
this.notification.add(
`Template loaded ${res.node_count} nodes cloned into this part's tree.`,
{ type: "success" }
`Variant "${label}" added (${res.node_count} nodes).`,
{ type: "success" },
);
this.state.newVariantLabel = "";
await this.refresh();
// Hand off directly to the tree editor so the user can
// immediately start customising.
this.openRecipeEditor(res.root_id);
});
}
async onDuplicateVariant(variantId) {
const src = this.state.variants.find(v => v.id === variantId);
const proposed = window.prompt(
"Name for the duplicated variant:",
(src?.label || "Variant") + " (copy)",
);
if (!proposed) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/duplicate_variant", {
part_id: this.partId,
source_variant_id: variantId,
variant_label: proposed,
});
if (!res.ok) throw new Error(res.error || "Duplicate failed.");
this.notification.add(`Variant "${proposed}" created.`, { type: "success" });
await this.refresh();
this.openRecipeEditor(res.root_id);
});
}
async onRenameVariant(variantId) {
const v = this.state.variants.find(x => x.id === variantId);
const proposed = window.prompt("New label:", v?.label || "");
if (!proposed) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/rename_variant", {
part_id: this.partId,
variant_id: variantId,
variant_label: proposed,
});
if (!res.ok) throw new Error(res.error || "Rename failed.");
await this.refresh();
});
}
async onSetDefaultVariant(variantId) {
await this._busy(async () => {
const res = await rpc("/fp/part/composer/set_default_variant", {
part_id: this.partId,
variant_id: variantId,
});
if (!res.ok) throw new Error(res.error || "Set default failed.");
this.notification.add("Default variant updated.", { type: "success" });
await this.refresh();
});
}
async onDeleteVariant(variantId) {
const v = this.state.variants.find(x => x.id === variantId);
if (!window.confirm(`Delete variant "${v?.label || ""}"? This removes its tree.`)) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/delete_variant", {
part_id: this.partId,
variant_id: variantId,
});
if (!res.ok) throw new Error(res.error || "Delete failed.");
this.notification.add("Variant deleted.", { type: "success" });
await this.refresh();
});
}
async _busy(fn) {
this.state.busy = true;
try {
await fn();
} catch (err) {
this.notification.add(
`Load failed: ${err.message || err}`,
{ type: "danger" }
);
this.notification.add(err.message || String(err), { type: "danger" });
} finally {
this.state.loadingTemplate = false;
this.state.busy = false;
}
}
openRecipeEditor(rootId) {
const id = rootId || this.state.rootId;
if (!id) return;
// The existing fp_recipe_tree_editor reads recipe_id from
// this.props.action?.context — pass it via `context`, not `params`.
// Label the editor as "Process Editor …" so it doesn't collide with
// "Process Composer …" in the breadcrumb stack; the two pages are
// distinct roles and should read differently in the trail.
this.action.doAction({
type: "ir.actions.client",
tag: "fp_recipe_tree_editor",
@@ -137,10 +200,6 @@ export class FpPartProcessComposer extends Component {
}
backToPart() {
// clearBreadcrumbs: "Back" is semantically a RETURN, not a forward
// navigation — reset the stack to just the part form so repeated
// round-trips (part → composer → editor → back) don't accumulate
// duplicate entries.
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.part.catalog",

View File

@@ -5,6 +5,7 @@
Part of the Fusion Plating product family.
OWL template for the part-scoped Process Composer client action.
Sub 9 — multi-variant Composer.
-->
<templates xml:space="preserve">
@@ -36,53 +37,105 @@
</div>
</div>
<div class="o_fp_part_composer_loader">
<label>Load Existing Process:</label>
<select class="form-select" t-on-change="onSelectTemplate">
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id"
t-att-selected="tpl.id == state.selectedTemplateId">
<t t-esc="tpl.name"/>
</option>
</t>
</select>
<button class="btn btn-primary"
t-on-click="onLoadTemplate"
t-att-disabled="state.loadingTemplate or !state.selectedTemplateId">
<t t-if="state.loadingTemplate">
<i class="fa fa-spinner fa-spin"/>
<span> Loading…</span>
</t>
<t t-else="">
<t t-if="state.hasTree">Replace with Selected</t>
<t t-else="">Load</t>
</t>
</button>
</div>
<div class="o_fp_part_composer_tree">
<t t-if="state.hasTree">
<div class="o_fp_part_composer_hint">
<p>This part has a composed process tree. Click below to open the
full tree editor where you can add, remove, reorder, and configure
the process nodes.</p>
<button class="btn btn-primary o_fp_part_composer_editor_btn"
t-on-click="() => this.openRecipeEditor()">
<i class="fa fa-sitemap"/>
<span>Open Process Editor</span>
</button>
<div class="o_fp_part_composer_variants mt-3">
<h4>Process Variants</h4>
<p class="text-muted small">
Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
One variant is the default; order lines may pick another at entry time.
</p>
<t t-if="state.variants.length === 0">
<div class="o_fp_part_composer_empty">
<i class="fa fa-cogs fa-2x"/>
<p>No variants yet. Pick a template below and add the first one.</p>
</div>
</t>
<t t-else="">
<div class="o_fp_part_composer_empty">
<i class="fa fa-cogs fa-3x"/>
<p>No process composed yet.</p>
<p class="text-muted">
Pick a template above and click <strong>Load</strong> to get started.
</p>
</div>
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Default</th>
<th>Label</th>
<th>Recipe Name</th>
<th class="text-end">Nodes</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.variants" t-as="v" t-key="v.id">
<tr>
<td>
<t t-if="v.is_default">
<span class="badge bg-success">Default</span>
</t>
<t t-else="">
<button class="btn btn-link btn-sm p-0"
t-att-disabled="state.busy"
t-on-click="() => this.onSetDefaultVariant(v.id)">
Set Default
</button>
</t>
</td>
<td>
<strong t-esc="v.label"/>
</td>
<td class="text-muted" t-esc="v.name"/>
<td class="text-end" t-esc="v.node_count"/>
<td class="text-end">
<button class="btn btn-sm btn-primary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeEditor(v.id)">
<i class="fa fa-pencil"/> Edit
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.onDuplicateVariant(v.id)">
<i class="fa fa-copy"/> Duplicate
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.onRenameVariant(v.id)">
<i class="fa fa-i-cursor"/> Rename
</button>
<button class="btn btn-sm btn-outline-danger"
t-att-disabled="state.busy"
t-on-click="() => this.onDeleteVariant(v.id)">
<i class="fa fa-trash"/>
</button>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
<div class="o_fp_part_composer_loader mt-4">
<h4>Add Variant from Template</h4>
<div class="d-flex gap-2 align-items-center flex-wrap">
<label class="me-2">Template:</label>
<select class="form-select" style="max-width: 280px;"
t-on-change="onSelectTemplate">
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id"
t-att-selected="tpl.id == state.selectedTemplateId">
<t t-esc="tpl.name"/>
</option>
</t>
</select>
<input class="form-control" style="max-width: 240px;"
placeholder="Variant label (e.g. Standard ENP)"
t-att-value="state.newVariantLabel"
t-on-input="onNewLabelInput"/>
<button class="btn btn-primary"
t-on-click="onAddVariantFromTemplate"
t-att-disabled="state.busy or !state.selectedTemplateId">
<i class="fa fa-plus"/> Add Variant
</button>
</div>
<p class="text-muted small mt-1">
Leave the label blank to use the template name. The first variant added becomes the default automatically.
</p>
</div>
</t>
</div>
</t>

View File

@@ -35,6 +35,12 @@
action="action_fp_direct_order_wizard"
sequence="5"/>
<menuitem id="menu_fp_direct_order_drafts"
name="Direct Order Drafts"
parent="menu_fp_sales"
action="action_fp_direct_order_drafts"
sequence="6"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
parent="menu_fp_sales"

View File

@@ -167,20 +167,30 @@
<page string="Process" name="process">
<group>
<field name="default_process_id" readonly="1"
help="Use the Compose button to set up this part's process tree."/>
help="The variant used by default when an order line does not pick another."/>
<field name="process_variant_count" readonly="1"/>
</group>
<div class="mt-2">
<button name="action_open_part_composer" type="object"
string="Compose"
icon="fa-wrench"
class="btn-primary"
help="Open the Process Composer to load a template and edit this part's tree."/>
help="Open the Process Composer to manage this part's process variants."/>
</div>
<p class="text-muted mt-3">
The <strong>Compose</strong> button opens the Process Composer where you can
load a shared template and customise it for this part. When a job runs for
this part, work orders are generated from the composed tree.
The <strong>Compose</strong> button opens the Process Composer where you can add
multiple process <em>variants</em> for this part — for example "Standard ENP",
"Selective Masking", "Rework". One variant is flagged as default; estimators
may pick a different variant on a per-order basis.
</p>
<field name="process_variant_ids" readonly="1">
<list>
<field name="is_default_variant" widget="boolean_toggle" readonly="1"/>
<field name="variant_label"/>
<field name="name"/>
<field name="estimated_duration" optional="hide"/>
</list>
</field>
</page>
<page string="Dimensions &amp; Complexity" name="dimensions">
<group>

View File

@@ -13,12 +13,12 @@
<field name="arch" type="xml">
<form string="Quote Configurator">
<header>
<button name="action_create_quotation"
string="Create Quotation"
<button name="action_promote_to_direct_order"
string="Add to Direct Order"
type="object"
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
invisible="state != 'draft'"
help="Add this quote as a line on a Direct Order draft. Multiple quotes can land on the same draft so one PO covers them all."/>
<button name="action_recalculate_price"
string="Recalculate"
type="object"

View File

@@ -62,10 +62,9 @@
<button name="action_view_workorders"
type="object"
class="oe_stat_button"
icon="fa-cogs"
invisible="x_fc_workorder_count == 0">
icon="fa-cogs">
<field name="x_fc_workorder_count" widget="statinfo"
string="Active WOs"/>
string="Work Orders"/>
</button>
<button name="action_view_ncrs"
type="object"
@@ -93,6 +92,7 @@
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">
<field name="x_fc_po_number"/>
@@ -182,17 +182,29 @@
</group>
</page>
</xpath>
<!-- Make the standard customer-facing description column togglable.
The base sale.order line view shows `name` always; flipping it
to optional lets estimators hide/show it like the other columns. -->
<xpath expr="//field[@name='order_line']/list/field[@name='name']" position="attributes">
<attribute name="string">Customer-Facing</attribute>
<attribute name="optional">show</attribute>
</xpath>
<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_description_template_id"
domain="[('part_catalog_id', '=', x_fc_part_catalog_id)]"
options="{'no_create': True}"
context="{'default_part_catalog_id': x_fc_part_catalog_id}"
invisible="not x_fc_part_catalog_id"
optional="show"/>
<field name="x_fc_internal_description"
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id"
string="Variant"
options="{'no_create': True}"
invisible="not x_fc_part_catalog_id"
optional="show"/>
<field name="x_fc_thickness_id"
options="{'no_create': True}"
invisible="not x_fc_coating_config_id"

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>