changes
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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").',
|
||||
)
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & Complexity" name="dimensions">
|
||||
<group>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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