changes
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

This commit is contained in:
gsinghpal
2026-05-17 03:20:33 -04:00
parent f8586611c9
commit d3c5c25865
30 changed files with 712 additions and 183 deletions

View File

@@ -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': """

View File

@@ -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):

View File

@@ -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()