Split 49 modules/suites into independent git repos; untrack from monorepo
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

Each top-level module/suite folder is now its own private repo on GitHub
(gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial
commit. The monorepo no longer tracks them (added to .gitignore + git rm
--cached); working-tree files are retained on disk and managed in their
own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/,
tools/, AGENTS.md, WIP/obsolete dirs) and full history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_part_material
from . import fp_part_catalog
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_sale_description_template
from . import fp_part_description_version
from . import fp_additional_charge_type
from . import fp_so_job_sort
from . import fp_quote_configurator
from . import fp_serial
from . import sale_order
from . import sale_order_line
from . import account_move_line
from . import fp_sale_assembly
from . import res_partner
from . import fp_process_node
from . import product_pricelist

View File

@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 2 Task 19 - propagate the customer part reference from SO line to
# invoice line so customer-facing invoice PDFs can print the part number
# via the shared fusion_plating_reports.customer_line_header macro.
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def fp_customer_description(self):
"""Strip the "[code] product_name" prefix from line.name.
Mirror of sale.order.line.fp_customer_description so the shared
customer_line_description QWeb macro renders cleanly on invoice
PDFs too.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n---:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
help="Copied from sale.order.line on invoice creation so customer-"
"facing invoice PDFs can render the customer's part number.",
)
# ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_account_move_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Copied from sale.order.line for traceability. Multi-serial '
'support added 2026-04-28.',
)
x_fc_serial_id = fields.Many2one(
'fp.serial',
string='Serial Number',
index=True,
help='Back-compat alias of the first serial in x_fc_serial_ids. '
'Kept so legacy invoice templates that read the singular '
'continue to render.',
)
x_fc_job_number = fields.Char(
string='Job #', index=True,
help='Copied from sale.order.line.',
)
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Carried from the SO line - prints on the invoice PDF.',
)
# x_fc_customer_spec_id added by fusion_plating_quality.
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
help='Revision letter from the source SO line.',
)

View File

@@ -1,29 +0,0 @@
# -*- 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 fields, models
class FpAdditionalChargeType(models.Model):
"""A configurable, reusable 'additional charge' label (Tooling, Rush,
Setup, …) picked on the order-entry summary. Searchable + quick-create.
Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md
"""
_name = 'fp.additional.charge.type'
_description = 'Fusion Plating - Additional Charge Type'
_order = 'sequence, name'
name = fields.Char(string='Charge Type', required=True)
default_amount = fields.Monetary(
string='Default Amount', currency_field='currency_id',
help='Optional amount pre-filled when this type is picked on an '
'order. The operator can override it.',
)
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id,
)
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)

View File

@@ -1,80 +0,0 @@
# -*- 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, _
class FpPartDescriptionVersion(models.Model):
"""Immutable per-part snapshot of the internal + customer-facing
description entered on an order. A new version is written on sale
order confirm whenever the description changes
(fp.part.catalog._fp_save_description_version). The latest version
auto-loads into the next order line for the part.
Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md
"""
_name = 'fp.part.description.version'
_description = 'Fusion Plating - Part Description Version'
_order = 'part_catalog_id, version_no desc, id desc'
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part', required=True,
ondelete='cascade', index=True,
)
internal_description = fields.Text(string='Internal Description')
customer_facing_description = fields.Text(string='Customer-Facing Description')
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order', ondelete='set null')
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Order Line', ondelete='set null')
source_date = fields.Date(string='Date')
version_no = fields.Integer(string='Version', readonly=True)
name = fields.Char(string='Reference', readonly=True)
is_latest = fields.Boolean(string='Latest', default=False, index=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
related='part_catalog_id.partner_id', store=True, readonly=True,
)
active = fields.Boolean(default=True)
@api.model
def _fp_build_name(self, vals):
"""Title = '<SO name> · <date>', with a '(n)' suffix if a version
with that title already exists for the part (e.g. two lines of the
same part on one order)."""
so = (self.env['sale.order'].browse(vals['sale_order_id'])
if vals.get('sale_order_id') else None)
order_ref = so.name if so and so.name else _('Manual')
d = vals.get('source_date') or fields.Date.context_today(self)
base = '%s · %s' % (order_ref, d)
part_id = vals.get('part_catalog_id')
if part_id:
dup = self.search_count([
('part_catalog_id', '=', part_id),
('name', '=like', base + '%'),
])
if dup:
base = '%s (%d)' % (base, dup + 1)
return base
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
part_id = vals.get('part_catalog_id')
if part_id and not vals.get('version_no'):
prev = self.search(
[('part_catalog_id', '=', part_id)],
order='version_no desc', limit=1)
vals['version_no'] = (prev.version_no or 0) + 1
if not vals.get('name'):
vals['name'] = self._fp_build_name(vals)
vals['is_latest'] = True
records = super().create(vals_list)
# Exactly one latest per part - flip prior latest rows off.
for rec in records:
rec.part_catalog_id.description_version_ids.filtered(
lambda v, r=rec: v.id != r.id and v.is_latest
).write({'is_latest': False})
return records

View File

@@ -1,61 +0,0 @@
# -*- 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
class FpPartMaterial(models.Model):
"""Custom material library.
Lets shops define their own materials (e.g. "Aluminium 6061",
"Stainless 316", "Brass C360") instead of being limited to the
fixed Selection. Each material maps to a `category` that drives
legacy pricing-rule matching and the default density used for
material-weight rollups.
"""
_name = 'fp.part.material'
_description = 'Fusion Plating - Part Material'
_order = 'sequence, name'
_rec_name = 'name'
name = fields.Char(string='Material', required=True, translate=False)
sequence = fields.Integer(string='Sequence', default=10)
category = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'),
('stainless', 'Stainless Steel'), ('copper', 'Copper'),
('titanium', 'Titanium'), ('other', 'Other')],
string='Category', required=True, default='other',
help='Used for pricing-rule matching and to pick a default '
'density when one is not set explicitly.',
)
density = fields.Float(
string='Density (g/cm³)', digits=(8, 4),
help='Override the category default. Leave 0 to use the '
'category density (Aluminium 2.70, Steel 7.85, '
'Stainless 8.00, Copper 8.96, Titanium 4.51).',
)
notes = fields.Char(string='Notes', help='Internal note (alloy spec, source, etc.).')
active = fields.Boolean(string='Active', default=True)
_CATEGORY_DENSITY = {
'aluminium': 2.70,
'steel': 7.85,
'stainless': 8.00,
'copper': 8.96,
'titanium': 4.51,
'other': 7.85,
}
_sql_constraints = [
('fp_part_material_name_uniq', 'unique(name)',
'Material name must be unique.'),
]
def effective_density(self):
"""Return density override if set, else the category default."""
self.ensure_one()
if self.density and self.density > 0:
return self.density
return self._CATEGORY_DENSITY.get(self.category, 7.85)

View File

@@ -1,34 +0,0 @@
# -*- 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
class FpPricingComplexitySurcharge(models.Model):
"""Complexity-based surcharge line on a pricing rule."""
_name = 'fp.pricing.complexity.surcharge'
_description = 'Fusion Plating - Pricing Complexity Surcharge'
_order = 'complexity'
rule_id = fields.Many2one('fp.pricing.rule', string='Pricing Rule', required=True, ondelete='cascade')
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', required=True,
)
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

@@ -1,55 +0,0 @@
# -*- 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 fields, models
class FpPricingRule(models.Model):
"""Formula-based pricing rule.
Rules are matched by coating config, substrate material, and
certification level. The first matching rule (by sequence) wins.
Global rules (no filters set) act as fallbacks.
"""
_name = 'fp.pricing.rule'
_description = 'Fusion Plating - Pricing Rule'
_order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True)
# coating_config_id removed. Spec + recipe match keys live on
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
# cert_level (below) remain as generic filters.
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Substrate Material', help='Leave blank to match all materials.',
)
certification_level = fields.Selection(
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
string='Certification Level', help='Leave blank to match all levels.',
)
pricing_method = fields.Selection(
[('per_sqin', 'Per Square Inch'), ('per_sqft', 'Per Square Foot'),
('per_piece', 'Per Piece'), ('flat_rate', 'Flat Rate')],
string='Pricing Method', required=True, default='per_sqin',
)
currency_id = fields.Many2one('res.currency', string='Currency',
required=True, default=lambda self: self.env.company.currency_id)
base_rate = fields.Monetary(string='Base Rate', currency_field='currency_id',
help='Price per unit (sq in, sq ft, piece, or flat).')
thickness_factor = fields.Float(string='Thickness Factor', default=1.0,
help='Multiplier per mil of coating thickness. 1.0 = no adjustment.')
complexity_surcharge_ids = fields.One2many('fp.pricing.complexity.surcharge', 'rule_id',
string='Complexity Surcharges')
masking_rate_per_zone = fields.Monetary(string='Masking Rate / Zone', currency_field='currency_id')
setup_fee = fields.Monetary(string='Setup Fee', currency_field='currency_id',
help='One-time setup fee per batch.')
minimum_charge = fields.Monetary(string='Minimum Charge', currency_field='currency_id',
help='Floor price.')
rush_surcharge_percent = fields.Float(string='Rush Surcharge %')
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
notes = fields.Text(string='Notes')

View File

@@ -1,119 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Configurator-side extensions to fusion.plating.process.node.
Lives here (not in core fusion_plating) so the core module doesn't have
to depend on the configurator. Any field that references a model defined
in configurator - like fp.pricing.rule, fp.part.catalog - must be
declared here.
"""
from odoo import api, fields, models, _
class FpProcessNode(models.Model):
_inherit = 'fusion.plating.process.node'
# ---- Existing: pricing rule linkage (Steelhead "Use Price Builders") ----
pricing_rule_ids = fields.Many2many(
'fp.pricing.rule',
relation='fp_process_node_pricing_rule_rel',
column1='node_id',
column2='rule_id',
string='Price Builders',
help='Pricing rules to apply when this recipe is selected on a '
'quotation (mirrors Steelhead "Use Price Builders").',
)
# ---- Sub 3: part ownership + template reference + treatment UoM --------
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
ondelete='cascade',
index=True,
help='Populated on nodes that belong to a specific part\'s '
'composed process tree. NULL on shared templates.',
)
cloned_from_id = fields.Many2one(
'fusion.plating.process.node',
string='Cloned From',
ondelete='set null',
help='On a part-cloned node, points back at the source template '
'node it was copied from.',
)
treatment_uom = fields.Selection(
[
('lbs', 'Lbs (weight-based)'),
('sq_in', 'Sq in (area-based)'),
],
string='Treatment UoM',
help='How this process step is measured for costing / pricing. '
'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").',
)
# ---- Linked Parts (cloned recipes) --------------------------------------
# On a shared template recipe, count + open all part-cloned recipe
# roots that were copied from this template (cloned_from_id == self).
# Only meaningful on shared templates (part_catalog_id IS NULL,
# node_type='recipe').
cloned_recipe_count = fields.Integer(
string='Linked Part Recipes',
compute='_compute_cloned_recipe_count',
)
def _compute_cloned_recipe_count(self):
Node = self.env['fusion.plating.process.node']
groups = Node._read_group(
domain=[
('cloned_from_id', 'in', self.ids),
('node_type', '=', 'recipe'),
('part_catalog_id', '!=', False),
],
groupby=['cloned_from_id'],
aggregates=['__count'],
)
counts = {src.id: count for src, count in groups}
for rec in self:
rec.cloned_recipe_count = counts.get(rec.id, 0)
def action_open_cloned_recipes(self):
"""Open the list of part-cloned recipe roots that came from this
template (i.e. cloned_from_id == self)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Linked Parts - %s', self.name),
'res_model': 'fusion.plating.process.node',
'view_mode': 'list,form',
'domain': [
('cloned_from_id', '=', self.id),
('node_type', '=', 'recipe'),
('part_catalog_id', '!=', False),
],
'context': {
'search_default_group_part': 1,
},
}

View File

@@ -1,988 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import math
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpQuoteConfigurator(models.Model):
"""Persistent configurator session.
Collects part geometry, coating config, and pricing inputs.
Calculates a price from matching pricing rules. The estimator
can override the calculated price. Creates a sale.order when confirmed.
"""
_name = 'fp.quote.configurator'
_description = 'Fusion Plating - Quote Configurator'
_inherit = ['mail.thread']
_order = 'create_date desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
state = fields.Selection(
[('draft', 'Draft'),
('confirmed', 'Won (SO Created)'),
('lost', 'Lost'),
('expired', 'Expired'),
('cancelled', 'Cancelled')],
string='Status', default='draft', tracking=True,
)
# ---- Win/Loss tracking (T3.2) ----
lost_reason = fields.Selection(
[('price', 'Price'),
('lead_time', 'Lead Time'),
('tech_capability', 'Technical Capability'),
('spec_mismatch', 'Spec / Certification Mismatch'),
('no_bid', 'No-Bid'),
('no_response', 'Customer No-Response'),
('competitor', 'Lost to Competitor'),
('other', 'Other')],
string='Lost Reason', tracking=True,
)
lost_competitor_name = fields.Char(string='Competitor', tracking=True)
lost_details = fields.Text(string='Loss Notes')
won_date = fields.Date(string='Won Date', readonly=True)
lost_date = fields.Date(string='Lost Date', readonly=True)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part (Catalog)',
domain="[('partner_id', '=', partner_id)]",
help="Select from this customer's part catalog, or leave blank for a one-off.",
)
model_attachment_id = fields.Many2one(
related='part_catalog_id.model_attachment_id',
string='3D Model',
readonly=True,
)
drawing_attachment_ids = fields.Many2many(
related='part_catalog_id.drawing_attachment_ids',
string='Drawings',
readonly=True,
)
# -- Physical part properties (intrinsic, related from part catalog) --
bbox_summary_in = fields.Char(
related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
readonly=True,
)
volume_mm3 = fields.Float(
related='part_catalog_id.volume_mm3', string='Volume (mm³)',
readonly=True,
)
hole_count = fields.Integer(
related='part_catalog_id.hole_count', string='Holes',
readonly=True,
)
hole_summary = fields.Char(
related='part_catalog_id.hole_summary', string='Hole Summary',
readonly=True,
)
is_manifold = fields.Boolean(
related='part_catalog_id.is_manifold', string='Watertight',
readonly=True,
)
# -- Quote-editable fields that drive weight / effective area --
# These are independent from part catalog (working copy for this quote)
masking_area_sqin = fields.Float(
string='Masking Area (sq in)',
digits=(12, 4),
help='Surface area excluded from plating (masked surfaces).',
)
# Computed using CONFIGURATOR's substrate + part catalog's volume
# so changing substrate on the quote updates the weight live.
material_weight_kg = fields.Float(
string='Weight (kg)',
digits=(12, 4),
compute='_compute_material_weight_kg',
store=False,
help='Computed from part volume × this quote\'s substrate density. '
'Changing substrate on the quote updates weight immediately.',
)
# Computed using CONFIGURATOR's surface_area and masking_area
effective_area_sqin = fields.Float(
string='Effective Plating Area (sq in)',
digits=(12, 4),
compute='_compute_effective_area_sqin',
store=False,
help='Surface area minus masked area, using the values on this quote.',
)
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
def _compute_material_weight_kg(self):
"""Compute weight from part volume × THIS QUOTE'S substrate density.
Prefer the per-material density override; fall back to the
category default when only the legacy Selection is set.
"""
density_map = {
'aluminium': 2.70,
'steel': 7.85,
'stainless': 8.00,
'copper': 8.96,
'titanium': 4.51,
'other': 7.85,
}
for rec in self:
if not rec.volume_mm3:
rec.material_weight_kg = 0.0
continue
if rec.material_id:
density = rec.material_id.effective_density()
elif rec.substrate_material:
density = density_map.get(rec.substrate_material, 7.85)
else:
rec.material_weight_kg = 0.0
continue
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
def _compute_effective_area_sqin(self):
"""Surface area minus masking area, using THIS QUOTE'S values."""
for rec in self:
uom = rec.surface_area_uom or 'sq_in'
if uom == 'sq_in':
area_sqin = rec.surface_area or 0.0
elif uom == 'sq_ft':
area_sqin = (rec.surface_area or 0.0) * 144.0
elif uom == 'sq_cm':
area_sqin = (rec.surface_area or 0.0) / 6.4516
elif uom == 'sq_m':
area_sqin = (rec.surface_area or 0.0) * 1550.0
else:
area_sqin = rec.surface_area or 0.0
rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0))
drawing_count = fields.Integer(
string='Drawings',
compute='_compute_drawing_count',
)
first_drawing_id = fields.Many2one(
'ir.attachment', string='First Drawing',
compute='_compute_first_drawing',
inverse='_inverse_first_drawing',
)
@api.depends('part_catalog_id.drawing_attachment_ids')
def _compute_drawing_count(self):
for rec in self:
rec.drawing_count = len(rec.part_catalog_id.drawing_attachment_ids) if rec.part_catalog_id else 0
@api.depends('part_catalog_id.drawing_attachment_ids')
def _compute_first_drawing(self):
for rec in self:
atts = rec.part_catalog_id.drawing_attachment_ids if rec.part_catalog_id else False
rec.first_drawing_id = atts[0] if atts else False
def _inverse_first_drawing(self):
"""When user clears or replaces the first drawing in the configurator,
propagate that change to the part catalog's drawing list."""
for rec in self:
if not rec.part_catalog_id:
continue
atts = rec.part_catalog_id.drawing_attachment_ids
current_first = atts[0] if atts else False
new_first = rec.first_drawing_id
# Cleared
if current_first and not new_first:
rec.part_catalog_id.sudo().write({
'drawing_attachment_ids': [(3, current_first.id)],
})
# Replaced
elif new_first and current_first and new_first.id != current_first.id:
rec.part_catalog_id.sudo().write({
'drawing_attachment_ids': [
(3, current_first.id), (4, new_first.id),
],
})
# Added (no current first, new value set)
elif new_first and not current_first:
rec.part_catalog_id.sudo().write({
'drawing_attachment_ids': [(4, new_first.id)],
})
# -- Quick file upload (creates/updates part catalog automatically) --
upload_3d_file = fields.Binary(
string='Upload 3D File',
attachment=False,
help='Upload a STEP, IGES, or STL file. Auto-creates or updates the part catalog entry.',
)
upload_3d_filename = fields.Char(string='3D Filename')
upload_drawing = fields.Binary(
string='Upload Drawing',
attachment=False,
help='Upload a PDF drawing. Attaches to the part catalog entry.',
)
upload_drawing_filename = fields.Char(string='Drawing Filename')
# -- RFQ / PO document tracking (from the beginning of the quote) --
rfq_attachment_id = fields.Many2one(
'ir.attachment', string='RFQ Document', copy=False, tracking=True,
help="Customer's original Request for Quote document (PDF). "
"Transferred to the sale order on quotation.",
)
po_attachment_id = fields.Many2one(
'ir.attachment', string='Customer PO', copy=False, tracking=True,
help='Customer PO document if already received. '
'Transferred to the sale order on quotation.',
)
po_number_preliminary = fields.Char(
string='PO Number', copy=False, tracking=True,
help='Customer PO number if already known. '
'Transferred to the sale order on quotation.',
)
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
upload_rfq_filename = fields.Char(string='RFQ Filename')
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename')
# Renamed from coating_config_id (Phase E - Promote Customer Spec).
# Now points at the recipe directly. The quote's specification
# (customer-facing audit ref) is added by quality inherit as
# customer_spec_id.
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
required=True,
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
)
quantity = fields.Integer(string='Quantity', default=1, required=True)
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
# ----- Geometry (auto-filled from catalog or entered manually) ----------
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
surface_area_uom = fields.Selection(
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
string='Area UoM', default='sq_in',
)
thickness_requested = fields.Float(string='Requested Thickness', digits=(10, 4))
masking_zones = fields.Integer(string='Masking Zones')
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'),
('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', default='simple',
)
# Single source of truth: pick a material from the shared library.
# `substrate_material` below is now a stored compute mirroring
# `material_id.category` so legacy consumers (pricing rules, portal,
# data exports) keep working unchanged.
material_id = fields.Many2one(
'fp.part.material', string='Material',
ondelete='restrict',
help='Picks from the shared material library - same picker as '
'the Part Catalog. Create custom alloys (e.g. "Aluminium '
'6061") on the fly.',
)
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Material Category',
compute='_compute_substrate_material',
store=True, readonly=False, default='steel',
help='Auto-derived from the selected material. Drives pricing '
'rule matching and density defaults.',
)
@api.depends('material_id', 'material_id.category')
def _compute_substrate_material(self):
for rec in self:
if rec.material_id:
rec.substrate_material = rec.material_id.category
elif not rec.substrate_material:
rec.substrate_material = 'steel'
# ----- Options ----------------------------------------------------------
rush_order = fields.Boolean(string='Rush Order')
turnaround_days = fields.Integer(string='Turnaround (days)')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method', default='shipping_partner',
)
# ----- Pricing ----------------------------------------------------------
currency_id = fields.Many2one(
'res.currency', string='Currency',
required=True, default=lambda self: self.env.company.currency_id,
)
shipping_fee = fields.Monetary(string='Shipping Fee', currency_field='currency_id')
delivery_fee = fields.Monetary(string='Delivery Fee', currency_field='currency_id')
calculated_price = fields.Monetary(
string='Calculated Price', currency_field='currency_id',
compute='_compute_price', store=True,
)
price_breakdown_html = fields.Html(
string='Price Breakdown', compute='_compute_price', store=True,
)
estimator_override_price = fields.Monetary(
string='Final Price', currency_field='currency_id',
help='Estimator can override the calculated price.',
)
# ----- SO link ----------------------------------------------------------
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True, copy=False)
notes = fields.Text(string='Notes')
# -------------------------------------------------------------------------
# Auto-population from catalog
# -------------------------------------------------------------------------
@api.onchange('part_catalog_id')
def _onchange_part_catalog_id(self):
if self.part_catalog_id:
cat = self.part_catalog_id
self.surface_area = cat.surface_area
self.surface_area_uom = cat.surface_area_uom
self.complexity = cat.complexity
self.masking_zones = cat.masking_zones
# Pull the m2o material from the part - substrate_material
# auto-derives via the compute. Fall back to the legacy
# Selection only if the part has no material_id yet.
if cat.material_id:
self.material_id = cat.material_id
else:
self.substrate_material = cat.substrate_material
# Copy masking area too (for effective-area calculation)
self.masking_area_sqin = cat.masking_area_sqin
@api.onchange('recipe_id')
def _onchange_recipe_id(self):
if self.recipe_id and self.recipe_id.thickness_min:
self.thickness_requested = self.recipe_id.thickness_min
# -------------------------------------------------------------------------
# Price calculation
# -------------------------------------------------------------------------
@api.depends(
'surface_area', 'surface_area_uom', 'thickness_requested',
'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee',
'recipe_id',
)
def _compute_price(self):
for rec in self:
if not rec.recipe_id or not rec.surface_area:
rec.calculated_price = 0
rec.price_breakdown_html = ''
continue
rule = rec._find_matching_rule()
if not rule:
rec.calculated_price = 0
rec.price_breakdown_html = '<p class="text-muted">No matching pricing rule found.</p>'
continue
# --- Base calculation ---
area = rec._normalize_surface_area_to_sqin()
if rule.pricing_method == 'per_sqin':
unit_price = area * rule.base_rate
elif rule.pricing_method == 'per_sqft':
unit_price = (area / 144.0) * rule.base_rate
elif rule.pricing_method == 'per_piece':
unit_price = rule.base_rate
else: # flat_rate
unit_price = rule.base_rate
# --- Thickness scaling ---
# thickness_factor is a per-mil multiplier. A factor of 1.0
# means linear scaling by thickness (e.g. 3 mils = 3x price).
# A factor of 0.8 gives a volume discount (3 mils = 2.4x).
thickness = rec.thickness_requested or 1.0
unit_price *= thickness * rule.thickness_factor
# --- Complexity surcharge ---
surcharge_pct = 0
for line in rule.complexity_surcharge_ids:
if line.complexity == rec.complexity:
surcharge_pct = line.surcharge_percent
break
unit_price *= (1 + surcharge_pct / 100.0)
# --- Masking ---
masking_cost = (rec.masking_zones or 0) * rule.masking_rate_per_zone
# --- Quantity + batch setup fees ---
num_batches = (
math.ceil(rec.quantity / rec.batch_size) if rec.batch_size
else 1
)
total_setup = rule.setup_fee * num_batches
subtotal = (unit_price * rec.quantity) + masking_cost + total_setup
# --- Rush surcharge ---
rush_amount = 0
if rec.rush_order and rule.rush_surcharge_percent:
rush_amount = subtotal * (rule.rush_surcharge_percent / 100.0)
subtotal += rush_amount
# --- Minimum charge ---
if subtotal < rule.minimum_charge:
subtotal = rule.minimum_charge
# --- Delivery/shipping fees ---
total = subtotal + (rec.shipping_fee or 0) + (rec.delivery_fee or 0)
rec.calculated_price = total
# --- Build breakdown HTML ---
sym = rec.currency_id.symbol or '$'
lines = []
method_label = dict(
rule._fields['pricing_method'].selection
).get(rule.pricing_method, '')
lines.append(
'<tr><td>Base (%s)</td><td class="text-end">%s%.2f x %d</td></tr>'
% (method_label, sym, unit_price, rec.quantity)
)
if masking_cost:
lines.append(
'<tr><td>Masking (%d zones)</td><td class="text-end">%s%.2f</td></tr>'
% (rec.masking_zones, sym, masking_cost)
)
if total_setup:
lines.append(
'<tr><td>Setup Fee (x%d batches)</td><td class="text-end">%s%.2f</td></tr>'
% (num_batches, sym, total_setup)
)
if rush_amount:
lines.append(
'<tr><td>Rush Surcharge (%.0f%%)</td><td class="text-end">%s%.2f</td></tr>'
% (rule.rush_surcharge_percent, sym, rush_amount)
)
if rec.shipping_fee:
lines.append(
'<tr><td>Shipping</td><td class="text-end">%s%.2f</td></tr>'
% (sym, rec.shipping_fee)
)
if rec.delivery_fee:
lines.append(
'<tr><td>Delivery</td><td class="text-end">%s%.2f</td></tr>'
% (sym, rec.delivery_fee)
)
lines.append(
'<tr class="fw-bold"><td>Total</td><td class="text-end">%s%.2f</td></tr>'
% (sym, total)
)
rec.price_breakdown_html = (
'<table class="table table-sm"><thead><tr>'
'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
'<tbody>%s</tbody></table>'
'<p class="text-muted small">Rule: %s (seq %d)</p>'
% (''.join(lines), rule.name, rule.sequence)
)
def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters.
Scores rules by specificity - most specific match wins.
If no rule matches filters, returns None.
When the chosen recipe has `pricing_rule_ids` configured, the
search is constrained to those rules ("Use Price Builders"
semantics). Otherwise the whole active rule set is considered.
Spec-tier scoring is added by an inherit in
fusion_plating_quality (where customer.spec lives).
"""
recipe = self.recipe_id or False
builder_rules = (
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
)
if builder_rules:
rules = builder_rules.filtered('active').sorted(
lambda r: (r.sequence, r.id)
)
else:
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
best = None
best_score = -1
for rule in rules:
score = 0
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
if score > best_score:
best_score = score
best = rule
return best
def _normalize_surface_area_to_sqin(self):
"""Convert surface area to square inches for calculation."""
area = self.surface_area or 0
uom = self.surface_area_uom
if uom == 'sq_ft':
return area * 144.0
elif uom == 'sq_cm':
return area * 0.155
elif uom == 'sq_m':
return area * 1550.0
return area # sq_in
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'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.recipe_id:
raise UserError(_(
'Pick a recipe 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):
"""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.'))
if self.sale_order_id:
raise UserError(_('A quotation has already been created for this configurator.'))
price = self.estimator_override_price or self.calculated_price
# Find or create a generic service product for plating
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1
)
if not product:
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
})
recipe_name = self.recipe_id.name if self.recipe_id else ''
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_configurator_id': self.id,
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
# Transfer RFQ / PO documents from configurator (if any)
'x_fc_rfq_attachment_id': self.rfq_attachment_id.id if self.rfq_attachment_id else False,
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
'x_fc_po_number': self.po_number_preliminary or False,
'x_fc_po_received': bool(self.po_attachment_id),
# Mirror the PO# into Odoo's standard client_order_ref so
# the customer portal, every standard report, and every
# third-party integration can read the PO without knowing
# about our custom field.
'client_order_ref': self.po_number_preliminary or False,
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': '%s - %s (x%d)' % (recipe_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
# Propagate part + recipe to the LINE.
# fusion_plating_jobs._fp_auto_create_job filters lines
# by x_fc_part_catalog_id; without it, no fp.job spawns.
# Spec carry-over to SO line is handled by the quality
# inherit (sale_order_line_inherit.create override).
'x_fc_part_catalog_id': (
self.part_catalog_id.id if self.part_catalog_id else False
),
'x_fc_process_variant_id': (
self.recipe_id.id if self.recipe_id else False
),
})],
}
so = self.env['sale.order'].create(so_vals)
self.write({
'sale_order_id': so.id,
'state': 'confirmed',
'won_date': fields.Date.today(),
})
self.message_post(
body=Markup(_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.')) % (so.id, so.name),
)
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}
@api.onchange('upload_3d_file')
def _onchange_upload_3d_file(self):
"""When a 3D file is uploaded, auto-create/update part catalog entry."""
if not self.upload_3d_file or not self.partner_id:
return
import base64
import os
fname = self.upload_3d_filename or 'model.step'
raw = base64.b64decode(self.upload_3d_file)
# Create attachment
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_3d_file,
'mimetype': 'application/octet-stream',
})
# Auto-create or update part catalog with revision tracking
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
# Part already has a 3D model - create a new revision
old_part = self.part_catalog_id
old_part.is_latest_revision = False
root = old_part.parent_part_id or old_part
from .fp_part_catalog import _bump_revision_label
new_label = _bump_revision_label(old_part.revision)
new_part = old_part.copy({
'revision': new_label,
'revision_date': fields.Datetime.now(),
'revision_note': f'Updated 3D model: {fname}',
'parent_part_id': root.id,
'is_latest_revision': True,
'model_attachment_id': att.id,
})
self.part_catalog_id = new_part.id
new_part._compute_surface_area_from_model()
self.surface_area = new_part.surface_area
self.surface_area_uom = new_part.surface_area_uom
elif self.part_catalog_id:
# Part exists but no 3D model yet - just attach
self.part_catalog_id.model_attachment_id = att.id
self.part_catalog_id._compute_surface_area_from_model()
self.surface_area = self.part_catalog_id.surface_area
self.surface_area_uom = self.part_catalog_id.surface_area_uom
else:
# No part catalog - create new entry
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'model_attachment_id': att.id,
})
self.part_catalog_id = part.id
part._compute_surface_area_from_model()
self.surface_area = part.surface_area
self.surface_area_uom = part.surface_area_uom
# Post to chatter so user sees confirmation (only if record is saved)
if self.id and not isinstance(self.id, models.NewId):
self.sudo().message_post(
body=Markup(_('3D model attached: <b>%s</b> - surface area: %.4f %s')) % (
fname, self.surface_area, self.surface_area_uom or ''),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Clear the upload field (data is now on the part catalog)
self.upload_3d_file = False
self.upload_3d_filename = False
@api.onchange('upload_drawing')
def _onchange_upload_drawing(self):
"""When a drawing is uploaded, attach to part catalog entry."""
if not self.upload_drawing or not self.partner_id:
return
fname = self.upload_drawing_filename or 'drawing.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_drawing,
'mimetype': 'application/pdf',
})
if self.part_catalog_id:
self.part_catalog_id.sudo().write({
'drawing_attachment_ids': [(4, att.id)],
})
part = self.part_catalog_id
else:
import os
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'drawing_attachment_ids': [(4, att.id)],
})
self.part_catalog_id = part.id
# Post to chatter so user sees confirmation (only if record is saved)
if self.id and not isinstance(self.id, models.NewId):
self.sudo().message_post(
body=Markup(_('Drawing attached: <b>%s</b> (linked to part %s)')) % (
fname, part.name),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
self.upload_drawing = False
self.upload_drawing_filename = False
@api.onchange('upload_rfq_file')
def _onchange_upload_rfq_file(self):
"""When an RFQ file is uploaded, create attachment + link it."""
if not self.upload_rfq_file:
return
fname = self.upload_rfq_filename or 'rfq.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_rfq_file,
'mimetype': 'application/pdf',
})
self.rfq_attachment_id = att.id
self.upload_rfq_file = False
self.upload_rfq_filename = False
@api.onchange('upload_po_file')
def _onchange_upload_po_file(self):
"""When a PO file is uploaded, create attachment + link it."""
if not self.upload_po_file:
return
fname = self.upload_po_filename or 'po.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_po_file,
'mimetype': 'application/pdf',
})
self.po_attachment_id = att.id
self.upload_po_file = False
self.upload_po_filename = False
def action_view_rfq(self):
self.ensure_one()
if not self.rfq_attachment_id:
return
return {
'type': 'ir.actions.client',
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.rfq_attachment_id.id,
'title': _('RFQ - %s') % (self.rfq_attachment_id.name or ''),
},
}
def action_view_po(self):
self.ensure_one()
if not self.po_attachment_id:
return
return {
'type': 'ir.actions.client',
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.po_attachment_id.id,
'title': _('PO - %s') % (self.po_attachment_id.name or ''),
},
}
def action_recalculate_price(self):
"""Recalculate surface area from 3D model and recompute price."""
self.ensure_one()
msg = _('No 3D model to calculate from.')
# Recalculate surface area from part catalog's 3D model
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
result = self.part_catalog_id._compute_surface_area_from_model()
if not result.get('error'):
self.surface_area = self.part_catalog_id.surface_area
self.surface_area_uom = self.part_catalog_id.surface_area_uom
msg = _('Surface area: %.4f %s | Price: %.2f %s') % (
self.surface_area, self.surface_area_uom or '',
self.calculated_price, self.currency_id.symbol or '$',
)
else:
msg = result['error']
# Post result to chatter so user sees it after form reload
self.message_post(
body=msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return False
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reset_draft(self):
self.write({'state': 'draft', 'won_date': False, 'lost_date': False})
def action_mark_lost(self):
"""Move this quote to 'lost' state. Caller should populate
`lost_reason` first - a simple validation enforces that."""
for rec in self:
if not rec.lost_reason:
from odoo.exceptions import UserError
raise UserError(_(
'Please set a Lost Reason before marking this quote lost.'
))
rec.write({
'state': 'lost',
'lost_date': fields.Date.today(),
})
rec.message_post(
body=_('Quote marked lost - reason: %s') % dict(
rec._fields['lost_reason'].selection
).get(rec.lost_reason, rec.lost_reason),
)
def action_mark_expired(self):
for rec in self:
rec.write({'state': 'expired', 'lost_date': fields.Date.today()})
def action_open_3d_fullscreen(self):
"""Open the 3D model viewer in a full-screen dialog (same window)."""
self.ensure_one()
att = self.model_attachment_id
if not att:
return
return {
'type': 'ir.actions.client',
'tag': 'fp_3d_viewer_open',
'params': {
'attachment_id': att.id,
'name': att.name or '',
},
}
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_part_catalog(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_save_to_catalog(self):
"""Push this quote's geometry/material edits back to the master part catalog.
Writes: material_id (preferred) / substrate_material (fallback),
surface_area, surface_area_uom, masking_area_sqin,
masking_zones, complexity.
Only available when a part catalog entry is linked.
"""
self.ensure_one()
if not self.part_catalog_id:
raise UserError(_('No part catalog entry linked to this configurator.'))
vals = {
'surface_area': self.surface_area,
'surface_area_uom': self.surface_area_uom,
'masking_area_sqin': self.masking_area_sqin,
'masking_zones': self.masking_zones,
'complexity': self.complexity,
}
if self.material_id:
vals['material_id'] = self.material_id.id
else:
vals['substrate_material'] = self.substrate_material
self.part_catalog_id.write(vals)
self.message_post(
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Saved to Catalog'),
'message': _('Part catalog updated with quote geometry and substrate.'),
'type': 'success',
'sticky': False,
},
}
def action_view_drawings(self):
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
self.ensure_one()
if self.first_drawing_id:
return {
'type': 'ir.actions.client',
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.first_drawing_id.id,
'title': _('Drawing - %s') % (self.first_drawing_id.name or ''),
},
}
# No drawing: fall back to part catalog
if not self.part_catalog_id:
return
return {
'type': 'ir.actions.act_window',
'name': _('Drawings - %s') % self.part_catalog_id.name,
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -1,69 +0,0 @@
# -*- 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
class FpSaleAssembly(models.Model):
"""Hierarchical kit / assembly on a sale order line.
A sale.order.line can carry child parts that make up an assembly.
Useful when the customer sends a kit (e.g. housing + cover + two
bolts) and each sub-part needs its own receive count + processing
status but they all bill as one kit.
Phase D11 shipped minimal: just the data model. Full UX (hierarchy
kanban, procurement tracking) is a follow-on.
"""
_name = 'fp.sale.assembly'
_description = 'Fusion Plating - Sales Order Assembly'
_order = 'sequence, id'
name = fields.Char(string='Assembly Name', required=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Parent SO Line',
required=True, ondelete='cascade',
)
order_id = fields.Many2one(
'sale.order', related='sale_order_line_id.order_id',
store=True, readonly=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id', store=True, readonly=True,
)
line_ids = fields.One2many(
'fp.sale.assembly.line', 'assembly_id',
string='Assembly Lines',
)
ship_to = fields.Char(string='Ship To')
count = fields.Integer(string='Count', default=1)
procured_count = fields.Integer(
string='Procured Count',
compute='_compute_procured_count',
)
completed_at = fields.Datetime(string='Completed At')
@api.depends('line_ids.procured_qty')
def _compute_procured_count(self):
for rec in self:
rec.procured_count = sum(rec.line_ids.mapped('procured_qty'))
class FpSaleAssemblyLine(models.Model):
_name = 'fp.sale.assembly.line'
_description = 'Fusion Plating - Assembly Line'
_order = 'sequence, id'
name = fields.Char(string='Part Number', required=True)
sequence = fields.Integer(default=10)
assembly_id = fields.Many2one(
'fp.sale.assembly', required=True, ondelete='cascade',
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
qty_per_assembly = fields.Float(string='Qty / Assembly', default=1.0)
procured_qty = fields.Float(string='Procured Qty', default=0.0)

View File

@@ -1,82 +0,0 @@
# -*- 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 fields, models
class FpSaleDescriptionTemplate(models.Model):
"""Saved description snippets - most often attached to a specific part.
Real-world usage: a plating shop keeps 3-5 canned descriptions PER
PART because the same customer part runs with different masking,
packaging, or spec-callout variations. With 3,500 parts and 5
variants each, that's ~17,500 rows - so descriptions are scoped
primarily by part, with optional fallback to customer / coating /
global.
When a user creates a new order:
1. If a part is picked, show templates for that part first.
2. Else show templates for the customer.
3. Else show templates for the coating.
4. Else show global (generic) templates.
"""
_name = 'fp.sale.description.template'
_description = 'Fusion Plating - Sale Order Line Description Template'
_order = 'part_catalog_id, sequence, name'
name = fields.Char(
string='Template Name', required=True,
help='Short name shown in the picker (e.g. "Standard masking", "With threaded holes masked").',
)
# Sub 2 - dual descriptions. Replaces the legacy `description` field
# (dropped in Phase C / Task 27). Migration Step 3 duplicated the old
# value into both columns; Step 6 drops the old column.
internal_description = fields.Text(
string='Internal Description',
required=True,
help='What the shop floor sees on the WO / traveler. Never on '
'customer documents.',
)
customer_facing_description = fields.Text(
string='Customer-Facing Description',
required=True,
help='Prints on the SO, invoice, packing slip, and BoL.',
)
sequence = fields.Integer(default=10)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
ondelete='cascade', index=True,
help='If set, this description belongs to one specific customer '
'part - it only appears in the picker when this part is on '
'the order. Leave blank for generic fallback templates.',
)
# Related fields - surface the part's partner for search & grouping
# without writing it twice.
partner_id = fields.Many2one(
'res.partner', string='Customer',
related='part_catalog_id.partner_id', store=True, readonly=True,
)
# coating_config_id removed; templates can be customer- or part-
# scoped. Spec-scoped templates are a future enhancement.
tag = fields.Selection(
[('standard', 'Standard'),
('masking', 'Masking / Selective'),
('rework', 'Rework / Strip'),
('aerospace', 'Aerospace'),
('nuclear', 'Nuclear'),
('packaging', 'Special Packaging'),
('other', 'Other')],
string='Category', default='standard',
)
active = fields.Boolean(default=True)
usage_count = fields.Integer(
string='Used', default=0, readonly=True,
help='Bumped each time this template is applied on an order line.',
)
def _register_usage(self):
"""Called by the wizard when the template is applied."""
for rec in self:
rec.usage_count = (rec.usage_count or 0) + 1

View File

@@ -1,315 +0,0 @@
# -*- 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
class FpSerial(models.Model):
"""Serial number registry.
One record per "occurrence of a part on an order line". The same part
ordered six months later gets a different serial. The serial is the
common thread linking the SO line to the MO, Delivery, and Invoice
records it spawns downstream.
Most serials are customer-supplied (pass-through from the customer's
own end-user); a smaller share are shop-generated via the sequence.
The registry is optional - SO lines can carry no serial at all.
"""
_name = 'fp.serial'
_description = 'Fusion Plating - Serial Number'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
required=True,
tracking=True,
help='Customer-supplied serial (most common) or shop-generated '
'sequence value. Typed-in values are accepted as-is.',
)
company_id = fields.Many2one(
'res.company', required=True,
default=lambda s: s.env.company,
)
sale_order_line_id = fields.Many2one(
'sale.order.line',
string='Source Sale Order Line',
ondelete='set null',
copy=False,
tracking=True,
)
sale_order_id = fields.Many2one(
related='sale_order_line_id.order_id',
store=True,
string='Sale Order',
)
customer_id = fields.Many2one(
related='sale_order_line_id.order_id.partner_id',
store=True,
string='Customer',
)
part_id = fields.Many2one(
related='sale_order_line_id.x_fc_part_catalog_id',
store=True,
string='Part',
)
notes = fields.Text(string='Notes')
# ==================================================================
# Phase 2 (2026-04-28) - per-serial state machine
# ==================================================================
# Each physical part owns its own state independent of the parent
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
# serials are independently trackable through the shop. State
# auto-promotes from job-step transitions (see fp.job.button_*
# overrides in fusion_plating_jobs); operator can also flip a
# single serial manually (e.g. mark serial #5 scrapped after a
# plating defect).
state = fields.Selection(
[
('received', 'Received'),
('racked', 'Racked'),
('in_process', 'In Process'),
('inspected', 'Inspected'),
('packed', 'Packed'),
('shipped', 'Shipped'),
('returned', 'Returned'),
('scrapped', 'Scrapped'),
('on_hold', 'On Hold'),
],
string='Status',
default='received',
required=True,
tracking=True,
index=True,
help='Per-serial workflow state. Transitions auto-promote from '
'parent job step events; supervisors can also flip a single '
'serial manually (e.g. scrap one part out of a 30-part rack).',
)
state_color = fields.Integer(
string='Status Color',
compute='_compute_state_color',
help='Kanban / many2many_tags color index derived from state.',
)
last_state_change = fields.Datetime(
string='Last Status Change',
readonly=True,
help='Timestamp of the most recent state transition. Auto-stamped '
'by every state-changing action.',
)
scrap_reason = fields.Text(
string='Scrap / Return Reason',
help='Captured when state transitions to scrapped or returned. '
'Surfaces on per-serial CoC entries (Phase 4).',
)
# Reverse from move log - Phase 3 will populate this directly when
# operators record per-serial moves on the tablet. Defined here so
# views can already render the count column.
move_count = fields.Integer(
compute='_compute_move_count',
string='# Moves',
)
@api.depends('state')
def _compute_state_color(self):
# Odoo color-index mapping aligned with the standard kanban palette.
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
mapping = {
'received': 8, # blue - fresh
'racked': 7, # sky - staged
'in_process': 3, # yellow - running
'inspected': 11, # olive - passed QC, ready to ship
'packed': 4, # green - boxed
'shipped': 4, # green - out the door
'returned': 2, # orange - back from customer
'scrapped': 1, # red
'on_hold': 1, # red - quality issue
}
for rec in self:
rec.state_color = mapping.get(rec.state, 0)
@api.depends_context('uid')
def _compute_move_count(self):
# Phase 3 will replace this with a real reverse link via
# fp.job.step.move.serial_ids (M2M added next phase).
# Defined here as 0-stub so views don't break on upgrade.
for rec in self:
rec.move_count = 0
# ------------------------------------------------------------------
# State transitions - log each one to chatter and stamp last_state_change
# ------------------------------------------------------------------
def _set_state(self, new_state, message=None):
"""Internal helper. Validates the source state, flips, stamps,
chatters. Raises UserError on illegal transitions."""
labels = dict(self._fields['state'].selection)
for rec in self:
old = rec.state
if old == new_state:
continue
# Terminal states are write-protected (operator must explicitly
# un-set via action_reopen if they really need to).
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
from odoo.exceptions import UserError
raise UserError(_(
'Serial %(name)s is %(old)s - cannot transition to '
'%(new)s. Use Reopen if this is a correction.'
) % {
'name': rec.name,
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
})
rec.state = new_state
rec.last_state_change = fields.Datetime.now()
body = message or _('Status %(old)s%(new)s by %(user)s') % {
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
'user': self.env.user.name,
}
rec.message_post(body=body)
return True
def action_mark_racked(self):
return self._set_state('racked')
def action_mark_in_process(self):
return self._set_state('in_process')
def action_mark_inspected(self):
return self._set_state('inspected')
def action_mark_packed(self):
return self._set_state('packed')
def action_mark_shipped(self):
return self._set_state('shipped')
def action_mark_returned(self):
return self._set_state('returned')
def action_mark_on_hold(self):
return self._set_state('on_hold')
def action_release_hold(self):
"""Lift on_hold and return the serial to in_process. Used when a
hold is resolved without scrap (e.g. visual blemish was actually
within tolerance after re-inspection)."""
return self._set_state('in_process')
def action_mark_scrapped(self):
"""Scrap a single serial. Operator should fill scrap_reason next
- view enforces it via a wizard form. Phase 3 hooks this into
the move log so the parent job's qty_scrapped auto-increments."""
return self._set_state('scrapped')
def action_reopen(self):
"""Manager-only override - un-pin a terminal state when a
correction is needed (e.g. wrong serial marked shipped). Audit
trail preserved via chatter; never silently rewrites history."""
for rec in self:
if not self.env.user.has_group('fusion_plating.group_fp_manager'):
from odoo.exceptions import UserError
raise UserError(_(
'Only the Plating Manager group can reopen a terminal '
'serial state. Contact your shop manager.'
))
return self._set_state('in_process', message=_(
'Serial reopened by %s - terminal state reverted for correction.'
) % self.env.user.name)
# Reverse link to invoice lines - safe here because account.move.line
# lives in this same module. Production (mrp) and delivery (logistics)
# reverse links are defined in their own modules' fp_serial inherits
# to keep module load order consistent.
invoice_line_ids = fields.One2many(
'account.move.line', 'x_fc_serial_id',
string='Invoice Lines',
)
invoice_ids = fields.Many2many(
'account.move',
compute='_compute_invoice_ids',
string='Invoices',
)
invoice_count = fields.Integer(compute='_compute_counts')
# production_count / delivery_count are declared in the inheriting
# modules (bridge_mrp / logistics) so the O2Ms exist alongside them.
_sql_constraints = [
('fp_serial_name_company_uniq',
'unique(company_id, name)',
'Serial number must be unique within the company.'),
]
# ---- Computes ------------------------------------------------------------
@api.depends('invoice_line_ids.move_id')
def _compute_counts(self):
# Base compute sets invoice_count only. bridge_mrp + logistics
# override this to also populate production_count / delivery_count.
for rec in self:
rec.invoice_count = len(rec.invoice_line_ids.mapped('move_id'))
@api.depends('invoice_line_ids.move_id')
def _compute_invoice_ids(self):
for rec in self:
rec.invoice_ids = rec.invoice_line_ids.mapped('move_id')
# ---- Actions -------------------------------------------------------------
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',
}
def action_view_productions(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Manufacturing Orders'),
'res_model': 'mrp.production',
'domain': [('id', 'in', self.production_ids.ids)],
'view_mode': 'list,form',
}
def action_view_deliveries(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Deliveries'),
'res_model': 'fusion.plating.delivery',
'domain': [('id', 'in', self.delivery_ids.ids)],
'view_mode': 'list,form',
}
def action_view_invoices(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Invoices'),
'res_model': 'account.move',
'domain': [('id', 'in', self.invoice_ids.ids)],
'view_mode': 'list,form',
}
def action_view_part(self):
self.ensure_one()
if not self.part_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.part.catalog',
'res_id': self.part_id.id,
'view_mode': 'form',
}

View File

@@ -1,58 +0,0 @@
# -*- 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
class FpSoJobSort(models.Model):
"""A user-defined grouping bucket for sale orders ("Job Sorting").
Same pattern as `fusion.plating.tank.section` - every shop slices its
SO backlog differently (by customer programme, by priority, by
fabricator group, by week, etc.). Sections are free-form, renameable,
quick-creatable from the M2O dropdown, and let users group the SO
list with fold/expand sections.
"""
_name = 'fp.so.job.sort'
_description = 'Fusion Plating - Sale Order Job Sort'
_order = 'sequence, name'
name = fields.Char(
string='Job Sorting',
required=True,
translate=True,
)
sequence = fields.Integer(string='Sequence', default=10)
color = fields.Integer(string='Color', default=0)
fold = fields.Boolean(
string='Folded by Default',
help='When set, this section appears collapsed in the grouped '
'SO list so the body rows are hidden until expanded.',
)
description = fields.Text(string='Description', translate=True)
active = fields.Boolean(default=True)
sale_order_ids = fields.One2many(
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
)
sale_order_count = fields.Integer(
compute='_compute_sale_order_count',
)
@api.depends('sale_order_ids')
def _compute_sale_order_count(self):
for rec in self:
rec.sale_order_count = len(rec.sale_order_ids)
def action_view_sale_orders(self):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_job_sort_id', '=', self.id)],
'context': {'default_x_fc_job_sort_id': self.id},
}

View File

@@ -1,32 +0,0 @@
# -*- 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, models
class ProductPricelist(models.Model):
"""Express Orders currency-picker enhancement (C1 / 2026-05-26).
When rendered with `fp_express_currency_picker=True` in context (set
by the Express Orders form's pricelist_id field), prefix the display
name with the currency code so the dropdown reads:
CAD - Public Pricelist (CAD)
USD - Westin USA Pricelist
EUR - Public Pricelist (EUR)
Elsewhere in Odoo (partner form, sale.order, settings), the standard
pricelist display name is unchanged.
"""
_inherit = 'product.pricelist'
@api.depends('name', 'currency_id')
@api.depends_context('fp_express_currency_picker')
def _compute_display_name(self):
super()._compute_display_name()
if self.env.context.get('fp_express_currency_picker'):
for pl in self:
if pl.currency_id and pl.currency_id.name not in (pl.display_name or ''):
pl.display_name = f"{pl.currency_id.name} - {pl.name}"

View File

@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models, _
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fc_part_catalog_ids = fields.One2many(
'fp.part.catalog', 'partner_id',
string='Part Catalog',
)
x_fc_part_count = fields.Integer(
string='Parts',
compute='_compute_part_count',
)
# Default lead-time band for new Express Orders. Set once per
# customer in their Plating profile; auto-copies onto every new
# Express Order via the partner-onchange on fp.direct.order.wizard.
x_fc_default_lead_time_min_days = fields.Integer(
string='Default Lead Time Min (days)',
default=0,
help='Pre-fills the Lead Time Min field on new Express Orders '
'for this customer. Operator can override per-order.',
)
x_fc_default_lead_time_max_days = fields.Integer(
string='Default Lead Time Max (days)',
default=0,
help='Pre-fills the Lead Time Max field on new Express Orders '
'for this customer. Operator can override per-order.',
)
def _compute_part_count(self):
for partner in self:
partner.x_fc_part_count = self.env['fp.part.catalog'].search_count([
('partner_id', '=', partner.id),
('is_latest_revision', '=', True),
])
def action_view_parts(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Parts - {self.name}',
'res_model': 'fp.part.catalog',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id), ('is_latest_revision', '=', True)],
'context': {'default_partner_id': self.id},
'target': 'current',
}
def action_fp_import_parts(self):
"""Open the CSV import wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Import Parts from CSV'),
'res_model': 'fp.part.catalog.import.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}
def action_fp_new_direct_order(self):
"""Open the Direct Order wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('New Direct Order'),
'res_model': 'fp.direct.order.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}

View File

@@ -1,904 +0,0 @@
# -*- 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 SaleOrder(models.Model):
_inherit = 'sale.order'
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
# x_fc_coating_config_id removed; specs live on customer.spec via
# the line-level x_fc_customer_spec_id (added by quality inherit).
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
x_fc_po_attachment_id = fields.Many2one(
'ir.attachment', string='PO Document', tracking=True,
)
x_fc_po_received = fields.Boolean(string='PO Received', tracking=True)
x_fc_rfq_attachment_id = fields.Many2one(
'ir.attachment', string='RFQ Document', tracking=True,
help="Customer's original Request for Quote document.",
)
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
upload_rfq_filename = fields.Char(string='RFQ Filename')
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename')
x_fc_po_override = fields.Boolean(string='PO Override',
help='Manager override - proceed without formal PO (handshake deal).')
x_fc_po_override_reason = fields.Text(string='Override Reason')
# Estimator-level "PO is coming later" flag. Unlike PO Override
# (permanent, manager-only), this one is time-boxed: the order
# confirms with no PO yet, but a chase activity is scheduled for
# po_expected_date so sales chases the customer for the paperwork.
x_fc_po_pending = fields.Boolean(
string='PO Pending',
tracking=True,
help='Customer will provide the PO later. Confirms the order '
'without a PO number, schedules a chase activity, and '
'shows a "PO Pending" ribbon on the form. Toggle off once '
'the real PO arrives and you\'ve entered it below.',
)
x_fc_po_expected_date = fields.Date(
string='PO Expected By',
tracking=True,
help='Date the customer promised to send the PO. A follow-up '
'activity is scheduled for this date when the order is '
'confirmed with PO Pending set.',
)
x_fc_invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy', tracking=True,
)
x_fc_deposit_percent = fields.Float(string='Deposit %',
help='Deposit percentage if strategy is Deposit.')
x_fc_progress_initial_percent = fields.Float(
string='Progress - Initial %',
default=50.0,
help='First-phase percentage for Progress Billing strategy. '
'Billed on SO confirmation; remainder billed on delivery.',
)
x_fc_final_invoice_id = fields.Many2one(
'account.move', string='Final Invoice', copy=False, readonly=True,
help='Final invoice auto-created on delivery for Progress Billing / '
'Net Terms strategies.',
)
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
# Lead Time (Phase D11) - promised production window in business
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
# so we render a proper expectation on the SO confirmation instead
# of the binary Standard/Rush we had before. Both fields default to
# 0 - `x_fc_lead_time_display` computes the right human-readable
# string (range / single value / Rush / Standard) for the PDF.
x_fc_lead_time_min_days = fields.Integer(
string='Lead Time Min (days)', tracking=True,
help='Lower bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_max_days = fields.Integer(
string='Lead Time Max (days)', tracking=True,
help='Upper bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_display = fields.Char(
string='Lead Time',
compute='_compute_lead_time_display',
help='Human-readable lead time string for the SO confirmation PDF.',
)
x_fc_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method', tracking=True,
)
x_fc_receiving_status = fields.Selection(
[('not_received', 'Not Received'), ('partial', 'Partial'),
('received', 'Received')],
string='Receiving Status', default='not_received', tracking=True,
help='State of the linked fp.receiving record(s). Inspection is '
"no longer a receiving state - Sub 8 moved part inspection "
'into the recipe (racking step), so receiving stops at '
'"received" (boxes counted, staged, closed).',
)
# ---- Direct Order rewrite (Phase A) ----
x_fc_customer_job_number = fields.Char(
string='Customer Job #',
help="Customer's internal job number for cross-referencing.",
tracking=True,
)
x_fc_job_sort_id = fields.Many2one(
'fp.so.job.sort',
string='Job Sorting',
ondelete='set null',
tracking=True,
help='Free-form bucket that groups this SO in the "Sale Orders '
'by Sorting" list view. Quick-create from the dropdown - '
'each shop slices its backlog differently (customer programme, '
'priority, week, etc.).',
)
# ---- Express Orders header-level (2026-05-26) ----
# 2026-05-27: changed from Char to Many2One - Material/Process Tag
# IS the order's recipe. Auto-applies to every line at confirm time.
x_fc_material_process = fields.Many2one(
'fusion.plating.process.node',
string='Material / Process Tag',
domain="[('node_type', '=', 'recipe')]",
help='Order-level recipe - auto-applies to every line. Individual '
'lines can still override via x_fc_process_variant_id.',
)
x_fc_internal_notes = fields.Text(
string='Order-Level Internal Notes',
help='Notes visible only to the estimator / planner / shop. Never '
'prints on customer-facing PDFs. Distinct from sale.order.note '
'which IS customer-facing (Terms & Conditions).',
)
x_fc_print_terms = fields.Boolean(
string='Print Terms on Customer Documents',
default=True,
help='When False, the Terms & Conditions (sale.order.note) is '
'suppressed on quote / SO / invoice / packing slip PDFs.',
)
x_fc_tooling_charge = fields.Monetary(
string='Tooling Charge',
currency_field='currency_id',
help='Optional one-time tooling fee from the Express Orders form. '
'Surfaced on the invoice as a separate line.',
)
x_fc_planned_start_date = fields.Date(
string='Planned Start Date', tracking=True,
)
x_fc_internal_deadline = fields.Date(
string='Internal Deadline', tracking=True,
)
x_fc_is_blanket_order = fields.Boolean(
string='Is Blanket Sales Order',
help='Blanket orders release parts in quantities over time, '
'often with a negotiated price and a fixed expiry.',
tracking=True,
)
x_fc_block_partial_shipments = fields.Boolean(
string='Block Partial Shipments',
help='If set, the order must ship all-or-nothing. '
'Partial pickings are blocked.',
tracking=True,
)
# ---- Phase D: SO detail view polish ----
x_fc_external_note = fields.Html(
string='External Notes',
help='Customer-visible notes. Appear on the SO acknowledgement '
'and customer portal.',
)
x_fc_internal_note = fields.Html(
string='Internal Notes',
help='Internal-only notes for the estimator / planner / shop floor.',
)
x_fc_ship_via = fields.Char(
string='Ship Via',
help='Carrier or delivery method name (UPS, FedEx, customer pickup, etc.).',
tracking=True,
)
x_fc_contact_phone = fields.Char(
related='partner_id.phone', string='Contact Phone', readonly=True,
)
x_fc_deadline_countdown = fields.Char(
string='Deadline',
compute='_compute_deadline_countdown',
)
# Drives the colour of the Deadline column. Computed in the same pass
# as x_fc_deadline_countdown so the buckets always agree with the
# human-readable countdown string.
x_fc_deadline_urgency = fields.Selection(
[('overdue', 'Overdue'),
('urgent', 'Due within 2 days'),
('safe', 'More than 2 days')],
string='Deadline Urgency',
compute='_compute_deadline_countdown',
)
x_fc_order_completion_date = fields.Date(
string='Order Completion Date',
compute='_compute_order_completion_date',
store=True,
help='When the LATEST line is actually due. Auto-rolled up from '
'each line\'s effective deadline. Distinct from Customer '
'Deadline (what we promised) - this reflects shop reality.',
)
x_fc_is_late_forecast = fields.Boolean(
string='Late Forecast',
compute='_compute_is_late_forecast',
store=True,
help='True when the rolled-up Order Completion Date sits past the '
'Customer Deadline. Suppressed on blanket orders since their '
'spans are intentionally long.',
)
x_fc_margin_amount = fields.Monetary(
string='Margin',
compute='_compute_margin', currency_field='currency_id',
)
x_fc_margin_percent = fields.Float(
string='Margin %',
compute='_compute_margin',
)
x_fc_margin_available = fields.Boolean(
string='Margin Available',
compute='_compute_margin',
help='False when no order line has a costed coating - the '
'margin fields should render "n/a" in the UI.',
)
# 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')
# Smart-button visibility helpers (post-Sub 11). The BOM Items kanban
# is only useful when the SO carries 2+ distinct parts; the By Job
# Group kanban is only useful when at least one line is tagged with
# x_fc_wo_group_tag. Default-hidden otherwise so the smart-button
# row stays clean for the typical single-part SO.
x_fc_distinct_part_count = fields.Integer(
string='# Distinct Parts',
compute='_compute_smart_button_visibility',
)
x_fc_has_wo_group_tag = fields.Boolean(
string='Has Job Group Tag',
compute='_compute_smart_button_visibility',
)
x_fc_wo_group_count = fields.Integer(
string='# Job Groups',
compute='_compute_smart_button_visibility',
help='Distinct x_fc_wo_group_tag values across this SO\'s lines.',
)
@api.depends('order_line.x_fc_part_catalog_id',
'order_line.x_fc_wo_group_tag')
def _compute_smart_button_visibility(self):
for rec in self:
parts = rec.order_line.mapped('x_fc_part_catalog_id')
rec.x_fc_distinct_part_count = len(parts)
tags = {
t for t in rec.order_line.mapped('x_fc_wo_group_tag') if t
}
rec.x_fc_has_wo_group_tag = bool(tags)
rec.x_fc_wo_group_count = len(tags)
# 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:
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',
compute='_compute_wo_completion',
help='Ratio of completed work orders, shown as "3/5 done".',
)
x_fc_invoiced_amount = fields.Monetary(
string='Invoiced',
compute='_compute_invoiced_amount',
currency_field='currency_id',
)
# Single "Job Status" pill rendered in the SO list. Pipeline order:
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
# Delivered → Invoiced → Paid → Cancelled.
# Rendered as an Html field so each kind can carry its own tint via
# an .fp-kind-* class - Bootstrap's 5 decoration-* slots aren't
# enough to give every phase a distinct colour. SCSS bundle at
# static/src/scss/fp_job_status_pill.scss owns the colour map.
x_fc_fp_job_status = fields.Html(
string='Job Status',
compute='_compute_fp_job_status',
sanitize=False,
help='Single at-a-glance pill that advances through the order '
'lifecycle: receiving → WO progress → shipping → invoicing.',
)
x_fc_fp_job_status_kind = fields.Selection(
[('muted', 'Draft (grey)'),
('warning', 'Awaiting / Partial (amber)'),
('primary', 'Ready / Milestone (purple)'),
('info', 'Active Work (blue)'),
('shipping', 'Shipping (cyan)'),
('delivered', 'Delivered (teal)'),
('invoiced', 'Invoiced (lime)'),
('paid', 'Paid (green bold)'),
('danger', 'Cancelled (red)')],
string='Job Status Kind',
compute='_compute_fp_job_status',
help='Colour category that backs the Job Status pill - also '
'usable for filtering / grouping in the list search panel.',
)
@api.depends(
'state',
'x_fc_receiving_status',
'x_fc_wo_completion',
'invoice_ids.state',
'invoice_ids.payment_state',
'invoice_ids.move_type',
)
def _compute_fp_job_status(self):
from markupsafe import Markup as _Markup
from markupsafe import escape as _escape
for so in self:
label, kind = self._fp_resolve_job_status(so)
so.x_fc_fp_job_status_kind = kind
so.x_fc_fp_job_status = _Markup(
'<span class="fp-job-status fp-kind-%s">%s</span>'
) % (_Markup(kind), _escape(label))
@staticmethod
def _fp_resolve_job_status(so):
# Terminal SO states first.
if so.state == 'cancel':
return ('Cancelled', 'danger')
if so.state in ('draft', 'sent'):
return ('Draft', 'muted')
# Invoice phase (terminal positive states).
posted = so.invoice_ids.filtered(
lambda m: m.state == 'posted'
and m.move_type in ('out_invoice', 'out_refund')
)
if posted and all(
m.payment_state in ('paid', 'in_payment') for m in posted
):
return ('Paid', 'paid')
# Shipping phase signals - read once.
ship_status = None
if 'x_fc_receiving_ids' in so._fields:
for r in so.x_fc_receiving_ids:
ship = (
r.x_fc_outbound_shipment_id
if 'x_fc_outbound_shipment_id' in r._fields else False
)
if not ship:
continue
# Latch the most-advanced status across all receivings.
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
cur = (
'delivered' if ship.status == 'delivered'
else 'in_transit' if ship.status == 'shipped'
else 'booked' if ship.status in ('confirmed', 'draft')
else None
)
if rank[cur] > rank[ship_status]:
ship_status = cur
if posted and ship_status == 'delivered':
return ('Invoiced', 'invoiced')
if ship_status == 'delivered':
return ('Delivered', 'delivered')
if ship_status == 'in_transit':
return ('In Transit', 'shipping')
# WO phase - figure out total steps and the current step name.
tot = 0
current_step_name = None
Job = so.env.get('fp.job')
if Job is not None and so.name:
jobs = Job.sudo().search([('origin', '=', so.name)])
if jobs:
steps = jobs.mapped('step_ids').sorted(
lambda s: (s.job_id.id, s.sequence)
)
tot = len(steps)
# Priority: in_progress → paused → next ready/pending.
current = (
steps.filtered(lambda s: s.state == 'in_progress')[:1]
or steps.filtered(lambda s: s.state == 'paused')[:1]
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
)
current_step_name = current.name if current else None
all_steps_done = tot > 0 and current_step_name is None
if all_steps_done:
if ship_status == 'booked':
return ('Ship Booked', 'shipping')
return ('Ready to Ship', 'primary')
if current_step_name:
return (current_step_name, 'info')
# Receiving phase (no WO yet).
recv = so.x_fc_receiving_status or 'not_received'
if recv == 'received':
return ('Ready to Start', 'primary')
if recv == 'partial':
return ('Parts Partial', 'warning')
return ('Awaiting Parts', 'warning')
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
def _compute_lead_time_display(self):
"""Render the lead time as a human-readable string for reports.
Priority order:
- Real min/max range set → "X-Y days" or "X days"
- Range not set, rush_order on → "Rush"
- Otherwise → "Standard"
"""
for so in self:
mn = so.x_fc_lead_time_min_days or 0
mx = so.x_fc_lead_time_max_days or 0
if mn and mx and mn != mx:
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
elif mx or mn:
so.x_fc_lead_time_display = '%d days' % (mx or mn)
elif so.x_fc_rush_order:
so.x_fc_lead_time_display = 'Rush'
else:
so.x_fc_lead_time_display = 'Standard'
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self.
Sub 11 - MRP is gone; we count fp.job.step completion instead of
mrp.workorder. The selection is the same shape: completed steps
out of total steps across every fp.job for this SO.
"""
for rec in self:
rec.x_fc_wo_completion = '0/0'
names = [so.name for so in self if so.name]
if not names:
return
if 'fp.job.step' not in self.env or 'fp.job' not in self.env:
return
Job = self.env['fp.job'].sudo()
Step = self.env['fp.job.step'].sudo()
jobs = Job.search([('origin', 'in', names)])
if not jobs:
return
job_to_origin = {j.id: j.origin for j in jobs}
# Odoo 19 - use _read_group with aggregates=['__count'].
rows = Step._read_group(
domain=[('job_id', 'in', jobs.ids)],
groupby=['job_id', 'state'],
aggregates=['__count'],
)
totals = {} # {origin: [total, done]}
for job_rec, state_val, count in rows:
origin = job_to_origin.get(job_rec.id)
if not origin:
continue
bucket = totals.setdefault(origin, [0, 0])
bucket[0] += count
if state_val == 'done':
bucket[1] += count
for rec in self:
if not rec.name:
continue
tot, done = totals.get(rec.name, [0, 0])
rec.x_fc_wo_completion = f'{done}/{tot}' if tot else '0/0'
# ---- Phase F: quotes list view polish ----
x_fc_follow_up_date = fields.Date(
string='Follow-Up Date',
help='Date to chase the customer for a decision on this quote.',
tracking=True,
)
x_fc_follow_up_user_id = fields.Many2one(
'res.users', string='Follow-Up Owner',
help='Who should chase the customer on the follow-up date.',
)
x_fc_email_status = fields.Selection(
[('draft', 'Draft'),
('sent', 'Sent'),
('opened', 'Opened'),
('won', 'Order Received')],
string='Email Status',
compute='_compute_email_status',
store=True,
)
x_fc_part_numbers_summary = fields.Char(
string='Part Numbers',
compute='_compute_part_numbers_summary',
)
x_fc_signed_at = fields.Datetime(
string='Signed On', tracking=True,
help='When the customer signed / accepted this quote.',
)
x_fc_signed_by = fields.Char(
string='Signed By', tracking=True,
help='Name of the customer signatory.',
)
x_fc_is_signed = fields.Boolean(
string='Signed', compute='_compute_is_signed', store=True,
)
@api.depends('x_fc_signed_at')
def _compute_is_signed(self):
for rec in self:
rec.x_fc_is_signed = bool(rec.x_fc_signed_at)
def action_mark_signed(self):
self.ensure_one()
self.write({
'x_fc_signed_at': fields.Datetime.now(),
'x_fc_signed_by': self.partner_id.name,
})
@api.depends('state')
def _compute_email_status(self):
"""Map state + mail tracking to a single visible pill.
- state draft => draft
- state sent => sent (or 'opened' if the customer partner has
a read notification for any email message on this SO)
- state sale / done => won
'Opened' is scoped to the CUSTOMER partner's notifications -
not internal CCs - to avoid false positives from sales-ops
viewing the thread.
"""
for rec in self:
if rec.state in ('sale', 'done'):
rec.x_fc_email_status = 'won'
continue
if rec.state == 'draft':
rec.x_fc_email_status = 'draft'
continue
# state == 'sent'
opened = False
if rec.id and rec.partner_id:
# Look for any read notification on any email message
# of this SO that targeted the customer.
notif_count = self.env['mail.notification'].sudo().search_count([
('mail_message_id.model', '=', 'sale.order'),
('mail_message_id.res_id', '=', rec.id),
('mail_message_id.message_type', '=', 'email'),
('res_partner_id', '=', rec.partner_id.id),
('is_read', '=', True),
])
opened = notif_count > 0
rec.x_fc_email_status = 'opened' if opened else 'sent'
@api.depends('order_line.x_fc_part_catalog_id.part_number')
def _compute_part_numbers_summary(self):
for rec in self:
parts = rec.order_line.mapped('x_fc_part_catalog_id.part_number')
parts = [p for p in parts if p]
if not parts:
rec.x_fc_part_numbers_summary = False
continue
if len(parts) <= 2:
rec.x_fc_part_numbers_summary = ', '.join(parts)
else:
rec.x_fc_part_numbers_summary = '%s, %s (+%d more)' % (
parts[0], parts[1], len(parts) - 2,
)
@api.depends('invoice_ids.amount_total', 'invoice_ids.state',
'invoice_ids.move_type')
def _compute_invoiced_amount(self):
for rec in self:
posted = rec.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
)
refunds = rec.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_refund'
)
rec.x_fc_invoiced_amount = (
sum(posted.mapped('amount_total'))
- sum(refunds.mapped('amount_total'))
)
def action_view_workorders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Work Orders',
'res_model': 'mrp.workorder',
'view_mode': 'list,form',
'domain': [('production_id.origin', '=', self.name)],
'context': {'search_default_group_production_id': 1},
}
# ---- Quick-nav counts for smart buttons (Phase D9 / D14) ----
x_fc_ncr_count = fields.Integer(
string='NCRs', compute='_compute_nav_counts',
)
x_fc_picking_count = fields.Integer(
string='Transfer Count', compute='_compute_nav_counts',
)
@api.depends('picking_ids')
def _compute_nav_counts(self):
for rec in self:
rec.x_fc_picking_count = len(rec.picking_ids)
# NCR counts - only if the module is installed.
ids = self.ids
NCR = self.env.get('fusion.plating.ncr')
ncr_counts = {}
if ids and NCR is not None and 'sale_order_id' in NCR._fields:
rows = NCR.sudo().read_group(
[('sale_order_id', 'in', ids)],
['sale_order_id'], ['sale_order_id'], lazy=False,
)
ncr_counts = {
(r['sale_order_id'][0] if r['sale_order_id'] else False):
r['__count']
for r in rows
}
for rec in self:
rec.x_fc_ncr_count = ncr_counts.get(rec.id, 0)
def action_view_pickings(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Transfers',
'res_model': 'stock.picking',
'view_mode': 'list,form',
'domain': [('id', 'in', self.picking_ids.ids)],
}
def action_view_ncrs(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'NCRs',
'res_model': 'fusion.plating.ncr',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
}
def action_view_bom_items(self):
"""Open SO lines grouped by part catalog (Phase D2)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'BOM Items - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list,form',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_bom_kanban').id, 'kanban'),
(False, 'list'),
(False, 'form'),
],
'domain': [('order_id', '=', self.id)],
}
def action_view_wo_perspective(self):
"""Open SO lines grouped by WO tag (Phase D10)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Lines by WO - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_wo_kanban').id, 'kanban'),
(False, 'list'),
],
'domain': [('order_id', '=', self.id)],
}
@api.depends('commitment_date')
def _compute_deadline_countdown(self):
from datetime import datetime
now = fields.Datetime.now()
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
for rec in self:
if not rec.commitment_date:
rec.x_fc_deadline_countdown = False
rec.x_fc_deadline_urgency = False
continue
target = rec.commitment_date
if isinstance(target, datetime):
delta = target - now
else:
from datetime import datetime as _dt
delta = _dt.combine(target, _dt.min.time()) - now
secs = int(delta.total_seconds())
if secs == 0:
rec.x_fc_deadline_countdown = 'due now'
rec.x_fc_deadline_urgency = 'overdue'
continue
past = secs < 0
abs_secs = abs(secs)
days = abs_secs // 86400
hours = (abs_secs % 86400) // 3600
mins = (abs_secs % 3600) // 60
bits = []
if days:
bits.append('%dd' % days)
if hours:
bits.append('%dh' % hours)
if mins and not days:
bits.append('%dm' % mins)
phrase = ' '.join(bits) or '<1m'
rec.x_fc_deadline_countdown = (
'overdue %s' % phrase if past else 'in %s' % phrase
)
if past:
rec.x_fc_deadline_urgency = 'overdue'
elif secs <= TWO_DAYS:
rec.x_fc_deadline_urgency = 'urgent'
else:
rec.x_fc_deadline_urgency = 'safe'
@api.depends(
'order_line.x_fc_effective_part_deadline',
'order_line.x_fc_archived',
)
def _compute_order_completion_date(self):
"""Roll up = max(line.x_fc_effective_part_deadline) over non-
archived lines. Empty / all-archived order returns False."""
for rec in self:
dates = [
line.x_fc_effective_part_deadline
for line in rec.order_line
if line.x_fc_effective_part_deadline and not line.x_fc_archived
]
rec.x_fc_order_completion_date = max(dates) if dates else False
@api.depends(
'x_fc_order_completion_date',
'commitment_date',
'x_fc_is_blanket_order',
)
def _compute_is_late_forecast(self):
for rec in self:
if rec.x_fc_is_blanket_order:
rec.x_fc_is_late_forecast = False
continue
commit = rec.commitment_date.date() if rec.commitment_date else False
rec.x_fc_is_late_forecast = bool(
rec.x_fc_order_completion_date
and commit
and rec.x_fc_order_completion_date > commit
)
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Margin computation - stub.
Pre-promote-customer-spec, this rolled up cost from
fp.coating.config.unit_cost. Coating Config is retired; cost
data on the recipe is a future enhancement (backlog). Until
then, margin is "not available" and the UI hides the fields.
"""
for rec in self:
rec.x_fc_margin_available = False
rec.x_fc_margin_amount = 0.0
rec.x_fc_margin_percent = 0.0
@api.onchange('upload_rfq_file')
def _onchange_upload_rfq_file(self):
"""Create attachment from uploaded binary and link it."""
if not self.upload_rfq_file:
return
fname = self.upload_rfq_filename or 'rfq.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_rfq_file,
'mimetype': 'application/pdf',
})
self.x_fc_rfq_attachment_id = att.id
self.upload_rfq_file = False
self.upload_rfq_filename = False
@api.onchange('upload_po_file')
def _onchange_upload_po_file(self):
"""Create attachment from uploaded binary, link it, and mark PO received."""
if not self.upload_po_file:
return
fname = self.upload_po_filename or 'po.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_po_file,
'mimetype': 'application/pdf',
})
self.x_fc_po_attachment_id = att.id
if not self.x_fc_po_received:
self.x_fc_po_received = True
self.upload_po_file = False
self.upload_po_filename = False
def action_view_rfq(self):
self.ensure_one()
if not self.x_fc_rfq_attachment_id:
return
return {
'type': 'ir.actions.client',
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.x_fc_rfq_attachment_id.id,
'title': 'RFQ - %s' % (self.x_fc_rfq_attachment_id.name or ''),
},
}
def action_view_po(self):
self.ensure_one()
if not self.x_fc_po_attachment_id:
return
return {
'type': 'ir.actions.client',
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.x_fc_po_attachment_id.id,
'title': 'PO - %s' % (self.x_fc_po_attachment_id.name or ''),
},
}
# ---- Sub 5 - auto-assign Job # on confirm -------------------------------
# Job # is the shop-floor reference that prints on travellers and WOs.
# Auto-assigned once at confirm so every confirmed line has one; still
# editable afterwards (clearable, overridable to match a customer scheme).
def action_confirm(self):
# Phase G of permissions overhaul: only Sales Manager+ can confirm
# Sale Orders. Sales Rep can save drafts but cannot move them to
# 'sale' state. The has_group() check resolves True for Sales Manager,
# Manager (implies Sales Manager via diamond), Quality Manager
# (implies Manager), and Owner (implies Quality Manager) - see
# spec Section 2.B.
if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'):
raise UserError(_(
'Only Sales Manager or higher can confirm Sale Orders. '
'Please ask a Sales Manager to confirm this quote.'
))
res = super().action_confirm()
Sequence = self.env['ir.sequence']
for so in self:
for line in so.order_line:
if line.display_type:
continue
if not line.x_fc_job_number:
line.x_fc_job_number = Sequence.next_by_code(
'fp.job.number'
) or False
# Per-part description history (spec 2026-05-29). After super() +
# the parent-number rename, so.name is the final order number.
for so in self:
for line in so.order_line:
if line.display_type:
continue
part = (line.x_fc_part_catalog_id
if 'x_fc_part_catalog_id' in line._fields else False)
if not part:
continue
part._fp_save_description_version(
internal_desc=line.x_fc_internal_description or '',
customer_desc=line.name or '',
order=so, line=line,
)
return res

View File

@@ -1,957 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def fp_customer_description(self):
"""Return line.name with the leading "[code] product_name" stripped.
Odoo's _compute_name re-prepends the product code + name on save,
polluting customer-facing PDFs with internal-product noise like
"[FP-SERVICE] Plating Service". This helper peels that prefix
off so the QWeb macros print only what the estimator actually
typed for the customer to see. Same logic mirrored on
account.move.line for invoice rendering.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
# Try the bracketed form first ("[CODE] Name"), then bare name.
# Whichever matches gets stripped along with any trailing
# newline / dash / em-dash separator.
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n---:').strip()
return name
@api.onchange('x_fc_part_catalog_id')
def _fp_onchange_part_load_description(self):
"""Pre-fill name (customer-facing) + x_fc_internal_description from
the part's latest description version, when empty (spec 2026-05-29)."""
for line in self:
part = line.x_fc_part_catalog_id
if not part:
continue
descs = part._fp_resolve_line_descriptions()
if not line.name and descs['customer_facing']:
line.name = descs['customer_facing']
if not line.x_fc_internal_description and descs['internal']:
line.x_fc_internal_description = descs['internal']
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
# Sub 2 - dual descriptions captured from a template row at order
# entry. `name` remains Odoo's standard customer-facing line
# description; x_fc_internal_description is ops-only (prints on WO).
# Nullable during Phase A; flipped to required in Phase C.
x_fc_internal_description = fields.Text(
string='Internal Description',
required=True,
help='Shop-floor instructions. Prints on WO / traveler. Never on customer docs.',
)
x_fc_description_template_id = fields.Many2one(
'fp.sale.description.template',
string='Description Template',
help='Which template row populated this line. Informational.',
)
# Specification picker (x_fc_customer_spec_id) is added by
# fusion_plating_quality. Legacy x_fc_coating_config_id +
# x_fc_treatment_ids removed.
x_fc_part_deadline = fields.Date(
string='Part Deadline Override',
help='Absolute-date manual override. When set, beats the days-offset '
'and the part\'s default lead time. Leave blank to fall through '
'to the offset, then part default, then the order\'s customer '
'deadline.',
)
x_fc_part_deadline_offset_days = fields.Integer(
string='Days Offset',
help='Manual override expressed as "+N days from the order\'s '
'customer deadline". Use this when you think in days rather '
'than absolute dates. Ignored if Part Deadline Override is set.',
)
x_fc_effective_part_deadline = fields.Date(
string='Effective Deadline',
compute='_compute_effective_part_deadline',
store=True,
help='Computed deadline that actually drives shop scheduling. '
'Resolution: explicit override → days offset → part default '
'lead time → order customer deadline.',
)
x_fc_effective_internal_deadline = fields.Date(
string='Shop Target',
compute='_compute_effective_internal_deadline',
store=True,
help='Internal deadline for this line - effective customer '
'deadline minus the order\'s shop buffer (commitment_date '
'internal_deadline gap). Clamped so it never exceeds the '
'effective customer deadline.',
)
x_fc_rush_order = fields.Boolean(string='Rush')
x_fc_wo_group_tag = fields.Char(
string='Work Order Group',
help='Lines sharing a tag (e.g. "WO#1") will be batched into one '
'manufacturing order when bridge_mrp generates MOs.',
)
x_fc_part_wo_description = fields.Text(
string='On Work Order',
help='Extra detail printed on the work order travelling sheet. '
'Separate from the customer-facing line description.',
)
x_fc_start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='For re-work jobs: pick the recipe step where this job '
'should begin. bridge_mrp skips ancestor steps.',
)
x_fc_is_one_off = fields.Boolean(
string='One-off Part',
help='Flag for prototype / non-catalog parts that should not be '
'reused after this order.',
)
x_fc_quote_id = fields.Many2one(
'fp.quote.configurator',
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
# Sub 9 (polished 2026-04-28) - process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
# save (see _onchange_process_variant_clone) so per-line edits never
# bleed across customers.
x_fc_process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick any recipe - the part\'s own variant, another part\'s '
'recipe, or a template from the library. If the chosen recipe '
'doesn\'t belong to this part, it will be cloned onto the part '
'when the order saves so per-line edits stay scoped. Use the '
'Customize button on the line to open the Process Composer.',
)
x_fc_save_as_default_process = fields.Boolean(
string='Save as Default for Part',
default=False,
help='When ticked, the chosen process variant becomes this part\'s '
'default on order save - future orders for the same part '
'pre-fill with this variant.',
)
x_fc_archived = fields.Boolean(
string='Archived',
default=False,
help='Archived lines are hidden from the default list view but '
'preserved for audit. Useful when a part is cancelled mid-order.',
)
# ---- Sub 5 - Order-line fields (serial / job# / thickness / revision) ---
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
# inherited fields - Odoo emits a warning and ignores it. Audit trail
# for these values lives on fp.serial.mail.thread instead.
#
# 2026-04-28 Phase 1 - multi-serial support. Customer can ship 30 parts
# with 30 distinct serials on a single line. The M2M is the source of
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
# serial so existing reports / smart buttons / downstream code that
# still reads the singular keep working unchanged.
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_sale_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
copy=False,
help='Customer-supplied serial numbers for the parts on this line. '
'Use the Bulk Add Serials button to paste a list, range-fill '
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
'the line quantity.',
)
x_fc_serial_id = fields.Many2one(
'fp.serial',
string='Primary Serial',
compute='_compute_primary_serial',
inverse='_inverse_primary_serial',
search='_search_primary_serial',
store=False,
copy=False,
help='First of the line\'s serials - back-compat alias kept so '
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
'reverse links) keeps working. Setting this prepends the '
'serial to the M2M.',
)
x_fc_serial_count = fields.Integer(
string='# Serials',
compute='_compute_serial_count',
)
@api.depends('x_fc_serial_ids')
def _compute_primary_serial(self):
for line in self:
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
def _inverse_primary_serial(self):
for line in self:
if not line.x_fc_serial_id:
continue
if line.x_fc_serial_id not in line.x_fc_serial_ids:
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
def _search_primary_serial(self, operator, value):
return [('x_fc_serial_ids', operator, value)]
@api.depends('x_fc_serial_ids')
def _compute_serial_count(self):
for line in self:
line.x_fc_serial_count = len(line.x_fc_serial_ids)
# ------------------------------------------------------------------
# Effective deadlines (Sub 12d)
# ------------------------------------------------------------------
@api.depends(
'x_fc_part_deadline',
'x_fc_part_deadline_offset_days',
'x_fc_part_catalog_id',
'x_fc_part_catalog_id.x_fc_default_lead_time_days',
'order_id.commitment_date',
'order_id.x_fc_planned_start_date',
)
def _compute_effective_part_deadline(self):
"""Resolution chain (first match wins):
1. explicit absolute-date override (x_fc_part_deadline)
2. days offset from commitment_date (x_fc_part_deadline_offset_days)
3. part's default lead time from planned_start_date
4. order's commitment_date (= customer profile cascade)
5. planned_start_date as last resort (orphan order with no deadline)
"""
for line in self:
order = line.order_id
# commitment_date is a Datetime in Odoo standard; coerce to
# date for arithmetic with our Date fields.
commit_dt = order.commitment_date if order else False
commit = commit_dt.date() if commit_dt else False
start = (
order.x_fc_planned_start_date if order
else False
) or fields.Date.context_today(line)
# 1. absolute-date override
if line.x_fc_part_deadline:
line.x_fc_effective_part_deadline = line.x_fc_part_deadline
continue
# 2. days offset from commitment
if line.x_fc_part_deadline_offset_days and commit:
line.x_fc_effective_part_deadline = (
commit + timedelta(days=line.x_fc_part_deadline_offset_days)
)
continue
# 3. part default lead time from planned_start
part_lead = (
line.x_fc_part_catalog_id
and line.x_fc_part_catalog_id.x_fc_default_lead_time_days
)
if part_lead:
line.x_fc_effective_part_deadline = (
start + timedelta(days=part_lead)
)
continue
# 4. order commitment (which itself derives from customer profile)
if commit:
line.x_fc_effective_part_deadline = commit
continue
# 5. last resort - planned start so the field is never null
line.x_fc_effective_part_deadline = start
@api.depends(
'x_fc_effective_part_deadline',
'order_id.commitment_date',
'order_id.x_fc_internal_deadline',
)
def _compute_effective_internal_deadline(self):
"""Apply the order's customer-vs-internal buffer to the line's
effective customer deadline. Buffer = commitment_date
x_fc_internal_deadline (the gap implied by customer profile).
Clamp result so it never exceeds the customer deadline.
"""
for line in self:
eff = line.x_fc_effective_part_deadline
if not eff:
line.x_fc_effective_internal_deadline = False
continue
order = line.order_id
commit_dt = order.commitment_date if order else False
commit = commit_dt.date() if commit_dt else False
internal = order.x_fc_internal_deadline if order else False
if commit and internal and commit >= internal:
buffer_days = (commit - internal).days
target = eff - timedelta(days=buffer_days)
# Clamp: internal can never sit after customer date
line.x_fc_effective_internal_deadline = (
target if target <= eff else eff
)
else:
# No buffer info → fall back to the customer date itself
line.x_fc_effective_internal_deadline = eff
x_fc_job_number = fields.Char(
string='Job #',
copy=False,
index=True,
help='Shop-floor reference for this line. Auto-sequenced on sale '
'order confirmation; editable. Blank is allowed.',
)
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Target thickness range as the operator types it, e.g. '
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text - '
'auto-fills from the last order for this (part, customer) '
'pair, falling back to the part\'s default range. Prints '
'verbatim on the cert, packing slip, and invoice.',
)
x_fc_is_lot_priced = fields.Boolean(string='Lot Priced')
x_fc_lot_total = fields.Monetary(
string='Lot Total', currency_field='currency_id')
# ---- Express Orders per-line flags (2026-05-26) ----
# Mirror fp.direct.order.line.{customer_line_ref, masking_enabled, bake_instructions}
# and persist past wizard confirm so _fp_apply_express_overrides_to_job can read them.
x_fc_customer_line_ref = fields.Char(
string='Customer Line Job #',
help='Per-line customer sub-reference (e.g. ABC, DEF). '
'Prints on customer docs (quote, SO, invoice, packing slip).',
)
x_fc_masking_enabled = fields.Boolean(
string='Masking Enabled',
default=True,
help='When False, the job-creation hook spawns fp.job.node.override '
'(included=False) for every masking + de_masking node on the recipe.',
)
x_fc_bake_instructions = fields.Text(
string='Bake Instructions',
help='Empty = bake steps are opted out of the job. Non-empty = bake '
'steps run, with this text shown on the operator tablet under '
'fp.job.step.instructions.',
)
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
string='Masking Reference(s)',
help='Masking reference image(s)/PDF(s) captured at Express order '
'entry; applied to the job\'s masking step at job creation so '
'the operator sees what to mask.',
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
copy=False,
readonly=True,
help='Revision letter / number at the moment the line was saved. '
'Preserved even if the part catalog revision is later edited '
'or the catalog row is removed.',
)
# Revision picker - non-stored compute that re-points x_fc_part_catalog_id
# to any revision of the same part number. The Part M2O itself is domain-
# filtered to latest revisions only, so the picker is what surfaces
# earlier revisions when the estimator needs one.
x_fc_revision_pick_id = fields.Many2one(
'fp.part.catalog',
string='Revision',
compute='_compute_revision_pick_id',
inverse='_inverse_revision_pick_id',
store=False,
help='Switch to a different revision of the same part number.',
)
@api.depends('x_fc_part_catalog_id')
def _compute_revision_pick_id(self):
for line in self:
line.x_fc_revision_pick_id = line.x_fc_part_catalog_id
def _inverse_revision_pick_id(self):
for line in self:
if line.x_fc_revision_pick_id:
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
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() so the polish runs on every
save path - onchange alone doesn't cover programmatic creates
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
"""
for line in self:
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
continue
recipe = line.x_fc_process_variant_id
if (not recipe.part_catalog_id
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
clone = line._fp_clone_recipe_to_part()
if clone and clone.id != recipe.id:
line.x_fc_process_variant_id = clone.id
recipe = clone
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
@api.model_create_multi
def create(self, vals_list):
"""Default `x_fc_internal_description` from `name` when a caller
creates a line programmatically without supplying the internal
description.
Sub 2 made `x_fc_internal_description` required. The UI-side
onchange fills it when the user picks a description template,
but programmatic creators (sale_mrp bridge, migration scripts,
external integrations, demo seeders) may not know about this
field. Instead of forcing every call site to update, fall back
to `name` - same rule the upgrade migration used when it
back-filled historical lines.
"""
Product = self.env['product.product']
Part = self.env['fp.part.catalog']
for vals in vals_list:
if not vals.get('x_fc_internal_description'):
# Try the explicit `name` first. If the caller didn't pass
# one (sale_mrp + some Odoo internals don't - they let the
# name compute from product_id later), fall back to the
# product's display_name so we have SOMETHING non-empty.
fallback = vals.get('name')
if not fallback and vals.get('product_id'):
prod = Product.browse(vals['product_id']).exists()
if prod:
fallback = prod.display_name or prod.name
vals['x_fc_internal_description'] = fallback or '-'
# Sub 5 - freeze the revision letter on the line at save time.
# Protects historical SOs from later edits to the catalog row.
if not vals.get('x_fc_revision_snapshot') and vals.get('x_fc_part_catalog_id'):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.revision:
vals['x_fc_revision_snapshot'] = part.revision
# Auto-fill thickness range - same logic as the onchange but
# for programmatic creators (wizard, sale_mrp, imports).
# Resolution: explicit > last-used (part, partner) > part default.
if (not vals.get('x_fc_thickness_range')
and vals.get('x_fc_part_catalog_id')):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part:
# Need partner_id from the parent order
partner_id = False
if vals.get('order_id'):
order = self.env['sale.order'].browse(vals['order_id']).exists()
if order:
partner_id = order.partner_id.id
if partner_id:
recent = self.search([
('x_fc_part_catalog_id', '=', part.id),
('order_id.partner_id', '=', partner_id),
('x_fc_thickness_range', '!=', False),
('x_fc_thickness_range', '!=', ''),
], order='create_date desc', limit=1)
if recent:
vals['x_fc_thickness_range'] = recent.x_fc_thickness_range
if (not vals.get('x_fc_thickness_range')
and getattr(part, 'x_fc_default_thickness_range', None)):
vals['x_fc_thickness_range'] = part.x_fc_default_thickness_range
lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
def write(self, vals):
# Sub 5 - keep the revision snapshot in lockstep with the line's
# part catalog pointer. Only refresh when the part changes; never
# overwrite a snapshot that's already been set on a historical line.
if 'x_fc_part_catalog_id' in vals:
Part = self.env['fp.part.catalog']
new_part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if new_part and new_part.revision:
vals.setdefault('x_fc_revision_snapshot', new_part.revision)
# Blank overrides keep their snapshot; explicit changes do too.
for line in self:
if line.x_fc_part_catalog_id.id != new_part.id:
line.x_fc_revision_snapshot = new_part.revision
result = super().write(vals)
# Only run the polish when something relevant actually changed -
# avoids re-running on every unrelated write (e.g. price updates).
if any(k in vals for k in (
'x_fc_process_variant_id',
'x_fc_part_catalog_id',
'x_fc_save_as_default_process',
)):
self._fp_apply_recipe_polish()
return result
@api.onchange('x_fc_description_template_id')
def _onchange_description_template(self):
"""When estimator picks a template, auto-fill both descriptions.
The customer-facing text goes into `name` (Odoo's line description,
prints on customer docs). The internal text goes into
x_fc_internal_description (prints on WO / traveler only).
Estimator can edit either field after the template is applied.
"""
if self.x_fc_description_template_id:
tpl = self.x_fc_description_template_id
if tpl.customer_facing_description:
self.name = tpl.customer_facing_description
if tpl.internal_description:
self.x_fc_internal_description = tpl.internal_description
def action_archive_line(self):
self.write({'x_fc_archived': True})
return True
def action_unarchive_line(self):
self.write({'x_fc_archived': False})
return True
def _prepare_invoice_line(self, **optional_values):
"""Carry x_fc_part_catalog_id + Sub 5 fields from SO line to invoice line.
Sub 2 Task 19 - lets the customer-facing invoice PDF render the
customer's part number via the shared customer_line_header macro
instead of the internal service SKU.
Sub 5 - also carry serial / job# / thickness / revision snapshot so
the same macro can print them unchanged on invoices.
"""
vals = super()._prepare_invoice_line(**optional_values)
if self.x_fc_part_catalog_id:
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
if self.x_fc_serial_ids:
# Carry the full M2M to the invoice line. Back-compat alias
# x_fc_serial_id will still resolve to the first one if any
# downstream code only reads the singular.
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
elif self.x_fc_serial_id:
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
if self.x_fc_job_number:
vals['x_fc_job_number'] = self.x_fc_job_number
if self.x_fc_thickness_range:
vals['x_fc_thickness_range'] = self.x_fc_thickness_range
if self.x_fc_revision_snapshot:
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
# x_fc_customer_spec_id carry-over is handled by an
# extension in fusion_plating_quality (the field lives there).
return vals
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_variant(self):
"""When the part changes, pre-fill the variant from the part's
default_process_id (if set) so the line carries a sensible
starting point. The estimator can override after.
Previously cleared the variant entirely when the part changed
(because the variant picker was scoped to the part). Now that
the picker is system-wide, we instead pre-fill from the part's
default - much more useful.
"""
for line in self:
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
# Spec auto-fill onchange lives in fusion_plating_quality
# (the customer.spec model lives there, so the inherit must too).
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
Edge cases handled:
* No recipe picked → no-op, return False.
* No part on the line → no-op (we need a part to scope the clone).
* Recipe already belongs to this part → no-op, return as-is.
* Recipe belongs to a different part / is a template / is unscoped
→ deep-copy via Odoo's standard recursive copy(), reparent the
clone onto this part, name-stamp it for traceability.
"""
self.ensure_one()
recipe = self.x_fc_process_variant_id
part = self.x_fc_part_catalog_id
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe # already scoped - nothing to do
# Clone - Odoo's default copy() recurses through child_ids when the
# field has copy=True. fp.process.node sets that on its tree, so
# one call gets us a full sub-tree clone.
clone_name = recipe.name or _('Untitled Recipe')
# If the source carried a part scope, preface the clone name with
# the customer's part number for quick identification on the
# variant dropdown later.
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
clone_name = '%s - %s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
'is_template': False, # never propagate template flag
'is_default_variant': False, # estimator opts in via toggle
})
return clone
def action_customize_process(self):
"""Open the Process Composer for this line's process variant.
Auto-clones first if the variant isn't yet scoped to this part -
the operator should never edit a recipe that's shared across
customers (their edits would bleed). After cloning, the line
ends up pointing at the fresh per-part copy.
"""
self.ensure_one()
if not self.x_fc_part_catalog_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a part on this line before customizing the process - '
'the recipe needs a part to scope the variant.'
))
if not self.x_fc_process_variant_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a process variant on this line first. To start from '
'scratch, use the part\'s Compose button instead.'
))
clone_or_existing = self._fp_clone_recipe_to_part()
if clone_or_existing.id != self.x_fc_process_variant_id.id:
self.x_fc_process_variant_id = clone_or_existing.id
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process - %s') % (
self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number
or '?'
),
'params': {
'part_id': self.x_fc_part_catalog_id.id,
'part_display': self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number,
'focus_variant_id': clone_or_existing.id,
},
'target': 'current',
}
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_thickness(self):
"""Auto-fill thickness range from last-used or part default.
Resolution order (first match wins):
1. Operator already typed a value → keep
2. Most recent SO line for (this part, this customer) with a
non-empty thickness_range → copy that
3. Part's x_fc_default_thickness_range → copy
4. Blank - operator types
"""
for line in self:
if line.x_fc_thickness_range:
continue
if not line.x_fc_part_catalog_id:
continue
partner = line.order_id.partner_id
# 2. Last-used for (part, customer)
if partner:
recent = self.env['sale.order.line'].search([
('x_fc_part_catalog_id', '=', line.x_fc_part_catalog_id.id),
('order_id.partner_id', '=', partner.id),
('x_fc_thickness_range', '!=', False),
('x_fc_thickness_range', '!=', ''),
('id', '!=', line.id or 0),
], order='create_date desc', limit=1)
if recent:
line.x_fc_thickness_range = recent.x_fc_thickness_range
continue
# 3. Part default
part_default = getattr(
line.x_fc_part_catalog_id, 'x_fc_default_thickness_range', None,
)
if part_default:
line.x_fc_thickness_range = part_default
def action_generate_serial(self):
"""Generate one new auto-sequenced serial and append it to the M2M.
Phase 1 polish: the legacy single-serial behaviour was "create one
serial and pin it to x_fc_serial_id". Now we append to the M2M so
repeated clicks add more serials (handy when the customer didn't
send any and the shop wants to assign N).
"""
self.ensure_one()
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
serial = self.env['fp.serial'].create({
'name': seq,
'sale_order_line_id': self.id,
})
self.x_fc_serial_ids = [(4, serial.id)]
return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this line."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial.bulk.add.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Bulk Add Serials'),
'context': {
'default_target_model': 'sale.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.product_uom_qty or 0),
},
}
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
def _check_serial_count_against_qty(self):
"""Block save when the operator has attached more serials than
the line quantity. Under-count is allowed (some customers ship
with serials only on a subset of parts).
"""
for line in self:
if line.x_fc_serial_ids and line.product_uom_qty:
n = len(line.x_fc_serial_ids)
if n > int(line.product_uom_qty):
raise ValidationError(_(
'Line "%(part)s": %(n)s serials attached but only '
'%(qty)s parts ordered. Either reduce the serial '
'list, increase the quantity, or split the line.'
) % {
'part': (line.x_fc_part_catalog_id.display_name
or line.product_id.display_name or ''),
'n': n,
'qty': int(line.product_uom_qty),
})
# ---- Customer references mirrored from parent sale.order ----------
# Related (not stored) - display-only on the line list so shipping /
# invoicing operators see the customer's job/PO ref per-line without
# navigating up to the order header.
x_fc_customer_job_number = fields.Char(
related='order_id.x_fc_customer_job_number',
string='Customer Job #',
readonly=True,
store=True,
)
x_fc_po_number = fields.Char(
related='order_id.x_fc_po_number',
string='Customer PO #',
readonly=True,
store=True,
)
x_fc_internal_deadline = fields.Date(
related='order_id.x_fc_internal_deadline',
string='Internal Deadline',
readonly=True,
store=True,
)
x_fc_planned_start_date = fields.Date(
related='order_id.x_fc_planned_start_date',
string='Planned Start',
readonly=True,
store=True,
)
x_fc_internal_note = fields.Html(
related='order_id.x_fc_internal_note',
string='Internal Note',
readonly=True,
store=True,
)
x_fc_external_note = fields.Html(
related='order_id.x_fc_external_note',
string='External Note',
readonly=True,
store=True,
)
x_fc_delivery_method = fields.Selection(
related='order_id.x_fc_delivery_method',
string='Delivery Method',
readonly=True,
store=True,
)
x_fc_ship_via = fields.Char(
related='order_id.x_fc_ship_via',
string='Ship Via',
readonly=True,
store=True,
)
x_fc_invoice_strategy = fields.Selection(
related='order_id.x_fc_invoice_strategy',
string='Invoice Strategy',
readonly=True,
store=True,
)
# ============================================================
# Express Orders backend helpers (Phase B - 2026-05-26)
# ============================================================
def _fp_apply_express_overrides_to_job(self, job):
"""Convert Express per-line flags into fp.job.node.override + step instructions.
Called from sale_order._fp_auto_create_job() immediately after Job.create
(creates override rows; step instructions skipped if no steps yet), and
again from fp.job.action_confirm() after _generate_steps_from_recipe()
(override rows recreate identically; step instructions land this time).
Idempotent: pre-deletes prior masking/bake override rows on each call.
Algorithm:
- x_fc_masking_enabled=False → opt out of masking + de_masking nodes
- x_fc_bake_instructions empty → opt out of baking nodes
- x_fc_bake_instructions non-empty → keep baking + write text to step.instructions
"""
self.ensure_one()
if not job or not job.recipe_id:
return
recipe = job.recipe_id
Override = self.env['fp.job.node.override'].sudo()
# Idempotency: clear prior masking/bake override rows on this job
prior = Override.search([
('job_id', '=', job.id),
('node_id.default_kind', 'in', ('mask', 'demask', 'bake')),
])
if prior:
prior.unlink()
msgs = []
# 1. Masking - opt out of masking + de_masking AS A PAIR
if not self.x_fc_masking_enabled:
nodes = recipe._fp_all_nodes_with_kind(('mask', 'demask'))
for node in nodes:
Override.create({
'job_id': job.id,
'node_id': node.id,
'included': False,
})
if nodes:
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
elif self.x_fc_masking_attachment_ids:
# Masking ON + Express reference file(s) attached → surface them on
# the mask step so the operator sees what to mask. Lands on the
# second call (after steps exist), same as bake below.
mask_steps = job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'mask'
)
if mask_steps:
mask_steps.sudo().write({
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
})
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
% len(self.x_fc_masking_attachment_ids))
# 2. Bake - empty = opt out; non-empty = keep + write step.instructions
bake_text = (self.x_fc_bake_instructions or '').strip()
bake_nodes = recipe._fp_all_nodes_with_kind(('bake',))
if not bake_text:
for node in bake_nodes:
Override.create({
'job_id': job.id,
'node_id': node.id,
'included': False,
})
if bake_nodes:
msgs.append(_('Baking steps opted out (per SO line)'))
else:
# Step instructions write only succeeds if steps exist. The
# helper is called twice - first call (before action_confirm)
# finds no steps and skips; second call (after step gen) lands.
bake_steps = job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'bake'
)
if bake_steps:
bake_steps.sudo().write({'instructions': bake_text})
msgs.append(_('Bake step instructions set to: %s') % bake_text)
# 3. Audit chatter post on the job (only on the call that actually wrote)
if msgs:
job.sudo().message_post(body='\n'.join('' + m for m in msgs))
def action_open_serial_bulk_add(self):
"""Open the existing fp.serial.bulk.add.wizard targeting this SO line.
Express Orders surfaces this as the inline '+ bulk' button on the
Part cell's serial row (post-confirm). The wizard model and its
action already handle both sale.order.line and fp.direct.order.line.
"""
self.ensure_one()
action = self.env.ref(
'fusion_plating_configurator.action_fp_serial_bulk_add_wizard'
).read()[0]
action['context'] = {
'default_target_model': 'sale.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.product_uom_qty or 0),
}
return action
def action_open_part(self):
"""Open the linked fp.part.catalog form in a modal."""
self.ensure_one()
if not self.x_fc_part_catalog_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_part_catalog_id.display_name,
'res_model': 'fp.part.catalog',
'views': [[False, 'form']],
'view_mode': 'form',
'res_id': self.x_fc_part_catalog_id.id,
'target': 'new',
}
def action_upload_drawing(self):
"""Attach a file (via context) to the line's part as a drawing.
Frontend calling pattern: read file picker → base64-encode →
set context['fp_drawing_file'] + context['fp_drawing_filename'] →
call this method. The drawing lives on the PART (not the line)
so future orders for the same part reuse it.
"""
self.ensure_one()
if not self.x_fc_part_catalog_id:
raise UserError(_('Pick or create a part on this line first.'))
file_data = self.env.context.get('fp_drawing_file')
filename = self.env.context.get('fp_drawing_filename', 'drawing.pdf')
if not file_data:
raise UserError(_('No file data received.'))
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': file_data,
'res_model': 'fp.part.catalog',
'res_id': self.x_fc_part_catalog_id.id,
})
self.x_fc_part_catalog_id.sudo().write({
'drawing_attachment_ids': [(4, att.id)],
})
self.x_fc_part_catalog_id.sudo().message_post(body=_(
'Drawing "%(name)s" uploaded by %(user)s from line %(seq)s on SO %(so)s.'
) % {
'name': filename,
'user': self.env.user.display_name,
'seq': self.sequence or self.id,
'so': self.order_id.name,
})
return {'type': 'ir.actions.client', 'tag': 'reload'}