changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.0.0',
|
||||
'version': '19.0.21.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -62,7 +62,7 @@ class FpDirectOrderLine(models.Model):
|
||||
process_variant_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
domain="[('id', 'in', recipe_choice_ids)]",
|
||||
ondelete='set null',
|
||||
help='Pick any recipe — the part\'s own variant, another part\'s '
|
||||
'recipe, or a template from the library. Cross-part picks '
|
||||
@@ -70,6 +70,44 @@ class FpDirectOrderLine(models.Model):
|
||||
'scoped. Use the Customize button to open the Process '
|
||||
'Composer for the chosen variant.',
|
||||
)
|
||||
# Pre-computed pick-list backing the process_variant_id m2o.
|
||||
# Scope: parent recipes (templates) + this part's own variants +
|
||||
# any recipe previously used on this customer's SO lines. Replaces
|
||||
# the previous wide-open domain that exposed every recipe in the
|
||||
# system. Recomputed on (part, customer) change.
|
||||
recipe_choice_ids = fields.Many2many(
|
||||
'fusion.plating.process.node',
|
||||
compute='_compute_recipe_choice_ids',
|
||||
string='Allowed Recipes (computed)',
|
||||
)
|
||||
|
||||
@api.depends('part_catalog_id', 'wizard_id.partner_id')
|
||||
def _compute_recipe_choice_ids(self):
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
SOL = self.env['sale.order.line']
|
||||
for rec in self:
|
||||
ids = set()
|
||||
# 1) Templates — the "parent recipes" the operator sees first.
|
||||
templates = Node.search([
|
||||
('parent_id', '=', False),
|
||||
('node_type', '=', 'recipe'),
|
||||
('is_template', '=', True),
|
||||
])
|
||||
ids.update(templates.ids)
|
||||
# 2) This part's own variants (scoped recipes already cloned
|
||||
# onto the part).
|
||||
if rec.part_catalog_id:
|
||||
ids.update(rec.part_catalog_id.process_variant_ids.ids)
|
||||
# 3) Recipes previously used on this customer's SO lines.
|
||||
# Capped to avoid sweeping every history row on big customers.
|
||||
if rec.wizard_id and rec.wizard_id.partner_id:
|
||||
used = SOL.search([
|
||||
('order_id.partner_id', '=', rec.wizard_id.partner_id.id),
|
||||
('x_fc_process_variant_id', '!=', False),
|
||||
], order='create_date desc', limit=500
|
||||
).mapped('x_fc_process_variant_id')
|
||||
ids.update(used.ids)
|
||||
rec.recipe_choice_ids = [(6, 0, list(ids))]
|
||||
save_as_default_process = fields.Boolean(
|
||||
string='Set as Part Default',
|
||||
help='When ticked, the chosen process variant becomes this part\'s '
|
||||
@@ -152,13 +190,33 @@ class FpDirectOrderLine(models.Model):
|
||||
# fusion_plating_quality (where customer_spec_id field lives).
|
||||
has_default_spec = bool(getattr(
|
||||
part, 'x_fc_default_customer_spec_id', False))
|
||||
# New-part auto-suggest: if no default spec exists, this is
|
||||
# likely a first-time use of the part. Auto-tick the
|
||||
# push_to_defaults toggle so whatever Sarah picks becomes
|
||||
# the saved default — surface a warning popup so she knows.
|
||||
# `is_one_off` always wins (operator opted out of catalog
|
||||
# persistence), so don't auto-tick in that case.
|
||||
# The quality inherit's _onchange_part_default_spec ALSO
|
||||
# falls back to the most recent SO line for (part, customer)
|
||||
# when the part default is empty. If that lookup will find
|
||||
# a hit, this is NOT a first-time use from the operator's
|
||||
# perspective — the spec will silently pre-fill from history.
|
||||
# Suppress the warning in that case so we don't pop a
|
||||
# misleading "no saved specification" alert right when the
|
||||
# spec actually does auto-fill.
|
||||
has_history_spec = False
|
||||
partner = rec.wizard_id and rec.wizard_id.partner_id
|
||||
SOL = self.env['sale.order.line']
|
||||
if (partner
|
||||
and 'x_fc_customer_spec_id' in SOL._fields):
|
||||
has_history_spec = bool(SOL.search_count([
|
||||
('x_fc_part_catalog_id', '=', part.id),
|
||||
('order_id.partner_id', '=', partner.id),
|
||||
('x_fc_customer_spec_id', '!=', False),
|
||||
]))
|
||||
# New-part auto-suggest: if no default spec exists AND no
|
||||
# SO-line history exists, this is genuinely a first-time use
|
||||
# of the part. Auto-tick the push_to_defaults toggle so
|
||||
# whatever Sarah picks becomes the saved default — surface a
|
||||
# warning popup so she knows. `is_one_off` always wins
|
||||
# (operator opted out of catalog persistence), so don't
|
||||
# auto-tick in that case.
|
||||
if (not has_default_spec
|
||||
and not has_history_spec
|
||||
and not rec.is_one_off
|
||||
and not rec.push_to_defaults):
|
||||
rec.push_to_defaults = True
|
||||
@@ -512,17 +570,61 @@ class FpDirectOrderLine(models.Model):
|
||||
def _onchange_part_defaults(self):
|
||||
"""Seed defaults when a part is picked.
|
||||
|
||||
Order of precedence for "remember last entered" fields
|
||||
(process_variant_id, unit_price, tax_ids):
|
||||
1. What the operator already typed on this line — never clobber
|
||||
2. Most recent SO line for (part_catalog_id, partner) — the
|
||||
"last entered" carry-over so repeat orders feel sticky
|
||||
3. Fall back to product / part defaults
|
||||
|
||||
Spec auto-fill is handled by an inherit in fusion_plating_quality
|
||||
(the customer_spec_id field lives there).
|
||||
"""
|
||||
if not self.part_catalog_id:
|
||||
return
|
||||
# Seed default taxes from the FP-SERVICE product, fiscal-position
|
||||
# mapped from the customer. Only fills when the user hasn't set
|
||||
# taxes manually.
|
||||
self._fp_seed_from_last_so_line()
|
||||
# Fall back to product taxes if no prior SO line found and the
|
||||
# operator hasn't set taxes manually.
|
||||
if not self.tax_ids:
|
||||
self._seed_default_taxes()
|
||||
|
||||
def _fp_seed_from_last_so_line(self):
|
||||
"""Carry the (process variant, unit price, taxes) from the most
|
||||
recent SO line for this (part, customer) onto the wizard line.
|
||||
|
||||
Skips any field the operator has already filled. Quietly no-ops
|
||||
when no prior SO line exists, when the part has no partner yet,
|
||||
or when the line is for a one-off part (no expectation of
|
||||
history). Called from the part_catalog_id onchange.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.part_catalog_id or not self.wizard_id:
|
||||
return
|
||||
partner = self.wizard_id.partner_id
|
||||
if not partner:
|
||||
return
|
||||
recent = self.env['sale.order.line'].search([
|
||||
('x_fc_part_catalog_id', '=', self.part_catalog_id.id),
|
||||
('order_id.partner_id', '=', partner.id),
|
||||
], order='create_date desc', limit=1)
|
||||
if not recent:
|
||||
return
|
||||
# Process variant — only if the line doesn't already have a pick.
|
||||
# The part's default still applies as a fallback in
|
||||
# _onchange_part_clears_variant above; this beats it when the
|
||||
# customer's last SO had a specific variant.
|
||||
if not self.process_variant_id and recent.x_fc_process_variant_id:
|
||||
self.process_variant_id = recent.x_fc_process_variant_id
|
||||
# Unit price — only when blank/zero. Avoids overwriting a
|
||||
# quote-driven or hand-typed price.
|
||||
if not self.unit_price and recent.price_unit:
|
||||
self.unit_price = recent.price_unit
|
||||
# Taxes — only when blank. The downstream
|
||||
# _seed_default_taxes() fallback handles the no-prior-line case.
|
||||
# NB: SO line field is `tax_ids` (Odoo 19 renamed from tax_id).
|
||||
if not self.tax_ids and recent.tax_ids:
|
||||
self.tax_ids = [(6, 0, recent.tax_ids.ids)]
|
||||
|
||||
def _seed_default_taxes(self):
|
||||
"""Pick taxes from the FP-SERVICE product, mapped through the
|
||||
customer's fiscal position when one is set."""
|
||||
@@ -690,9 +792,11 @@ class FpDirectOrderLine(models.Model):
|
||||
return clone
|
||||
|
||||
def _fp_apply_recipe_polish(self):
|
||||
"""Post-write step: auto-clone any cross-part recipe pick and
|
||||
honour the Save-as-Default toggle. Called from create() and
|
||||
write()."""
|
||||
"""Post-write step: auto-clone any cross-part recipe pick, set
|
||||
the freshly-cloned recipe as the part's default IF this is the
|
||||
part's very first variant, and honour the manual Save-as-Default
|
||||
toggle for repeat orders that explicitly want to flip the
|
||||
default. Called from create() and write()."""
|
||||
for line in self:
|
||||
if not line.part_catalog_id or not line.process_variant_id:
|
||||
continue
|
||||
@@ -703,7 +807,19 @@ class FpDirectOrderLine(models.Model):
|
||||
if clone and clone.id != recipe.id:
|
||||
line.process_variant_id = clone.id
|
||||
recipe = clone
|
||||
if line.save_as_default_process and recipe.part_catalog_id:
|
||||
# Auto-default rule: if the part has no other variants
|
||||
# besides the one we just attached, this recipe becomes its
|
||||
# default automatically. Estimators don't have to remember
|
||||
# to tick "Save as Default" for first-time parts.
|
||||
other_variants = (
|
||||
line.part_catalog_id.process_variant_ids - recipe
|
||||
)
|
||||
should_auto_default = (
|
||||
bool(recipe.part_catalog_id)
|
||||
and not other_variants
|
||||
)
|
||||
if (line.save_as_default_process or should_auto_default) \
|
||||
and recipe.part_catalog_id:
|
||||
line.part_catalog_id.action_set_default_variant(recipe.id)
|
||||
|
||||
def action_customize_process(self):
|
||||
|
||||
@@ -552,13 +552,19 @@ class FpDirectOrderWizard(models.Model):
|
||||
resolved_parts[line.id] = part
|
||||
# Build the line header. Specification is optional; when
|
||||
# missing, drop it from the header rather than printing
|
||||
# "False - PartName Rev A".
|
||||
# "False - PartName Rev A". Same defensive treatment for
|
||||
# part identifier and revision — `%s` on a False/NULL field
|
||||
# used to print the literal string "False".
|
||||
spec = getattr(line, 'customer_spec_id', False)
|
||||
spec_label = (spec.display_name if spec else '') or _('No spec')
|
||||
header = '%s - %s Rev %s (x%d)' % (
|
||||
# Prefer part_number (set on every saved part); fall back to
|
||||
# name; skip the segment entirely if both are missing.
|
||||
part_label = part.part_number or part.name or ''
|
||||
rev_suffix = (' Rev %s' % part.revision) if part.revision else ''
|
||||
header = '%s - %s%s (x%d)' % (
|
||||
spec_label,
|
||||
part.name,
|
||||
part.revision,
|
||||
part_label or _('Unspecified part'),
|
||||
rev_suffix,
|
||||
line.quantity,
|
||||
)
|
||||
extended = (line.line_description or '').strip()
|
||||
|
||||
Reference in New Issue
Block a user