changes
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user