This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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