folder rename
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# -*- 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_treatment
|
||||
from . import fp_part_catalog
|
||||
from . import fp_coating_config
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_quote_configurator
|
||||
from . import sale_order
|
||||
from . import res_partner
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- 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 FpCoatingConfig(models.Model):
|
||||
"""Coating configuration template.
|
||||
|
||||
Defines a specific coating setup: process type, phosphorus level,
|
||||
thickness range, spec reference, and required pre/post treatments.
|
||||
Used by the configurator to drive pricing and recipe selection.
|
||||
"""
|
||||
_name = 'fp.coating.config'
|
||||
_description = 'Fusion Plating — Coating Configuration'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Configuration', required=True, help='e.g. "EN Mid-Phos AMS 2404"')
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type', string='Process Type', required=True, ondelete='restrict',
|
||||
)
|
||||
recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node', string='Default Recipe',
|
||||
domain="[('node_type', '=', 'recipe')]",
|
||||
help='Default recipe template for this coating configuration.',
|
||||
)
|
||||
phosphorus_level = fields.Selection(
|
||||
[('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'),
|
||||
('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')],
|
||||
string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes.',
|
||||
)
|
||||
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
|
||||
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
|
||||
thickness_uom = fields.Selection(
|
||||
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
|
||||
string='Thickness UoM', default='mils',
|
||||
)
|
||||
spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"')
|
||||
certification_level = fields.Selection(
|
||||
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
|
||||
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
|
||||
string='Certification Level', default='commercial',
|
||||
)
|
||||
pre_treatment_ids = fields.Many2many(
|
||||
'fp.treatment', 'fp_coating_config_pre_treatment_rel', 'config_id', 'treatment_id',
|
||||
string='Pre-Treatments', domain="[('treatment_type', '=', 'pre')]",
|
||||
)
|
||||
post_treatment_ids = fields.Many2many(
|
||||
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
|
||||
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
description = fields.Text(string='Description')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
@@ -0,0 +1,561 @@
|
||||
# -*- 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 FpPartCatalog(models.Model):
|
||||
"""Customer part library.
|
||||
|
||||
Stores geometry, material, and complexity data for parts that
|
||||
customers send repeatedly. New orders reference existing catalog
|
||||
entries for instant re-quoting; one-off parts create new entries.
|
||||
"""
|
||||
_name = 'fp.part.catalog'
|
||||
_description = 'Fusion Plating — Part Catalog'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'partner_id, part_number, name'
|
||||
|
||||
name = fields.Char(string='Part Name', required=True, tracking=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True, ondelete='cascade',
|
||||
tracking=True, domain="[('customer_rank', '>', 0)]",
|
||||
)
|
||||
part_number = fields.Char(string='Part Number', tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
|
||||
revision = fields.Char(string='Revision', help='Revision letter or number (e.g. Rev: 1B).')
|
||||
revision_number = fields.Integer(string='Rev #', default=1)
|
||||
revision_note = fields.Char(string='Revision Note', help='What changed in this revision.')
|
||||
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now)
|
||||
parent_part_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Original Part',
|
||||
ondelete='set null', index=True,
|
||||
help='Links to the original part for revision history.',
|
||||
)
|
||||
is_latest_revision = fields.Boolean(string='Latest Revision', default=True)
|
||||
revision_ids = fields.One2many(
|
||||
'fp.part.catalog', 'parent_part_id', string='Revision History',
|
||||
)
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate Material', default='steel',
|
||||
)
|
||||
geometry_source = fields.Selection(
|
||||
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
|
||||
string='Geometry Source', default='manual',
|
||||
)
|
||||
model_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='3D Model File',
|
||||
help='STEP, STL, or IGES file.', tracking=True,
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings',
|
||||
)
|
||||
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='Surface Area UoM', default='sq_in',
|
||||
)
|
||||
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
|
||||
dimensions_length = fields.Float(string='Length', digits=(12, 4))
|
||||
dimensions_width = fields.Float(string='Width', digits=(12, 4))
|
||||
dimensions_height = fields.Float(string='Height', digits=(12, 4))
|
||||
complexity = fields.Selection(
|
||||
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', default='simple',
|
||||
)
|
||||
masking_zones = fields.Integer(string='Masking Zones', help='Number of areas requiring masking.')
|
||||
masking_description = fields.Text(string='Masking Description', help='e.g. "Mask threaded holes, mask bore ID"')
|
||||
has_blind_holes = fields.Boolean(string='Has Blind Holes')
|
||||
has_recesses = fields.Boolean(string='Has Recesses')
|
||||
has_threads = fields.Boolean(string='Has Threads')
|
||||
|
||||
# ---- Auto-extracted from 3D model (OCC-computed) ----
|
||||
volume_mm3 = fields.Float(
|
||||
string='Volume (mm³)', digits=(16, 2),
|
||||
help='Auto-calculated from 3D model.',
|
||||
)
|
||||
bbox_length_mm = fields.Float(string='BBox Length (mm)', digits=(12, 2))
|
||||
bbox_width_mm = fields.Float(string='BBox Width (mm)', digits=(12, 2))
|
||||
bbox_height_mm = fields.Float(string='BBox Height (mm)', digits=(12, 2))
|
||||
bbox_summary_in = fields.Char(
|
||||
string='Dimensions (in)',
|
||||
compute='_compute_bbox_summary_in',
|
||||
store=True,
|
||||
help='Bounding box L × W × H in inches.',
|
||||
)
|
||||
material_weight_kg = fields.Float(
|
||||
string='Material Weight (kg)', digits=(12, 4),
|
||||
compute='_compute_material_weight',
|
||||
store=True,
|
||||
help='volume × substrate density.',
|
||||
)
|
||||
is_manifold = fields.Boolean(
|
||||
string='Watertight (Manifold)',
|
||||
help='False indicates open/broken geometry — review before quoting.',
|
||||
)
|
||||
hole_count = fields.Integer(
|
||||
string='Holes',
|
||||
help='Real cylindrical holes/bosses detected (faces summing to ≥350° '
|
||||
'around an axis). Fillets and rounded edges are excluded.',
|
||||
)
|
||||
hole_summary = fields.Char(
|
||||
string='Hole Diameters',
|
||||
help='Holes grouped by diameter — e.g. "4× Ø10.2mm, 2× Ø7.9mm".',
|
||||
)
|
||||
masking_area_sqin = fields.Float(
|
||||
string='Masking Area (sq in)', digits=(12, 4),
|
||||
help='Total area excluded from plating (masked surfaces).',
|
||||
)
|
||||
effective_area_sqin = fields.Float(
|
||||
string='Effective Plating Area (sq in)', digits=(12, 4),
|
||||
compute='_compute_effective_area',
|
||||
store=True,
|
||||
help='Surface area minus masked area — used for per-sq-in pricing.',
|
||||
)
|
||||
|
||||
notes = fields.Html(string='Notes')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
# Substrate density mapping (g/cm³) for material weight calculation
|
||||
_SUBSTRATE_DENSITY = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85, # default to steel
|
||||
}
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material')
|
||||
def _compute_material_weight(self):
|
||||
for rec in self:
|
||||
if not rec.volume_mm3 or not rec.substrate_material:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
||||
# mm³ × g/cm³ × 1e-6 = kg
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@api.depends('bbox_length_mm', 'bbox_width_mm', 'bbox_height_mm')
|
||||
def _compute_bbox_summary_in(self):
|
||||
for rec in self:
|
||||
if not (rec.bbox_length_mm or rec.bbox_width_mm or rec.bbox_height_mm):
|
||||
rec.bbox_summary_in = False
|
||||
continue
|
||||
# mm to inches
|
||||
l = rec.bbox_length_mm / 25.4
|
||||
w = rec.bbox_width_mm / 25.4
|
||||
h = rec.bbox_height_mm / 25.4
|
||||
rec.bbox_summary_in = '%.2f × %.2f × %.2f in' % (l, w, h)
|
||||
|
||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||
def _compute_effective_area(self):
|
||||
for rec in self:
|
||||
# Convert surface_area to sq in
|
||||
uom = rec.surface_area_uom or 'sq_in'
|
||||
if uom == 'sq_in':
|
||||
area_sqin = rec.surface_area
|
||||
elif uom == 'sq_ft':
|
||||
area_sqin = rec.surface_area * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
area_sqin = rec.surface_area / 6.4516
|
||||
elif uom == 'sq_m':
|
||||
area_sqin = rec.surface_area * 1550.0
|
||||
else:
|
||||
area_sqin = rec.surface_area
|
||||
rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0))
|
||||
|
||||
sale_order_count = fields.Integer(
|
||||
string='Sale Orders', compute='_compute_sale_order_count',
|
||||
)
|
||||
configurator_count = fields.Integer(
|
||||
string='Quotes', compute='_compute_configurator_count',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
||||
'Part number must be unique per customer.'),
|
||||
]
|
||||
|
||||
def write(self, vals):
|
||||
"""Track changes to attachments and propagate to linked configurators."""
|
||||
# Snapshot before write
|
||||
snapshots = {}
|
||||
track_3d = 'model_attachment_id' in vals
|
||||
track_drawings = 'drawing_attachment_ids' in vals
|
||||
if track_3d or track_drawings:
|
||||
for rec in self:
|
||||
snapshots[rec.id] = {
|
||||
'model': rec.model_attachment_id,
|
||||
'drawings': set(rec.drawing_attachment_ids.ids),
|
||||
}
|
||||
|
||||
result = super().write(vals)
|
||||
|
||||
# Log changes after write
|
||||
for rec in self:
|
||||
snap = snapshots.get(rec.id)
|
||||
if not snap:
|
||||
continue
|
||||
|
||||
messages = []
|
||||
|
||||
# 3D model change
|
||||
if track_3d:
|
||||
old = snap['model']
|
||||
new = rec.model_attachment_id
|
||||
if not old and new:
|
||||
messages.append(_('<b>3D model attached:</b> %s') % new.name)
|
||||
elif old and not new:
|
||||
messages.append(_('<b>3D model removed:</b> %s') % old.name)
|
||||
elif old and new and old.id != new.id:
|
||||
messages.append(_('<b>3D model changed:</b> %s → %s') % (old.name, new.name))
|
||||
|
||||
# Drawing changes (added or removed)
|
||||
if track_drawings:
|
||||
old_ids = snap['drawings']
|
||||
new_ids = set(rec.drawing_attachment_ids.ids)
|
||||
added = new_ids - old_ids
|
||||
removed = old_ids - new_ids
|
||||
for att_id in added:
|
||||
att = self.env['ir.attachment'].browse(att_id)
|
||||
if att.exists():
|
||||
messages.append(_('<b>Drawing attached:</b> %s') % att.name)
|
||||
for att_id in removed:
|
||||
att = self.env['ir.attachment'].browse(att_id)
|
||||
# Browse even if deleted — may still have name if not purged
|
||||
name = att.exists() and att.name or f'#{att_id}'
|
||||
messages.append(_('<b>Drawing removed:</b> %s') % name)
|
||||
|
||||
if messages:
|
||||
body = '<br/>'.join(messages)
|
||||
# Post to part catalog chatter
|
||||
rec.message_post(
|
||||
body=body,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
# Propagate to linked configurator quotes (draft + confirmed)
|
||||
configurators = self.env['fp.quote.configurator'].search([
|
||||
('part_catalog_id', '=', rec.id),
|
||||
])
|
||||
for cfg in configurators:
|
||||
cfg.message_post(
|
||||
body=_('Part <b>%s</b>: %s') % (rec.name, body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _compute_sale_order_count(self):
|
||||
for part in self:
|
||||
part.sale_order_count = self.env['sale.order'].search_count(
|
||||
[('x_fc_part_catalog_id', '=', part.id)])
|
||||
|
||||
def _compute_configurator_count(self):
|
||||
for part in self:
|
||||
part.configurator_count = self.env['fp.quote.configurator'].search_count(
|
||||
[('part_catalog_id', '=', part.id)])
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
orders = self.env['sale.order'].search([('x_fc_part_catalog_id', '=', self.id)])
|
||||
if len(orders) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': orders.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sale Orders'),
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_part_catalog_id', '=', self.id)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_configurators(self):
|
||||
self.ensure_one()
|
||||
cfgs = self.env['fp.quote.configurator'].search([('part_catalog_id', '=', self.id)])
|
||||
if len(cfgs) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.quote.configurator',
|
||||
'res_id': cfgs.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Configurator Quotes'),
|
||||
'res_model': 'fp.quote.configurator',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('part_catalog_id', '=', self.id)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_revision(self):
|
||||
"""Create a new revision of this part. Copies all data, increments revision number."""
|
||||
self.ensure_one()
|
||||
# Mark current as no longer latest
|
||||
self.is_latest_revision = False
|
||||
# Determine the root part for the chain
|
||||
root = self.parent_part_id or self
|
||||
# Find highest revision number in chain
|
||||
all_revs = self.env['fp.part.catalog'].search([
|
||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||
])
|
||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
||||
new_rev = self.copy({
|
||||
'revision_number': max_rev + 1,
|
||||
'revision': f'Rev {max_rev + 1}',
|
||||
'revision_date': fields.Datetime.now(),
|
||||
'revision_note': False,
|
||||
'parent_part_id': root.id,
|
||||
'is_latest_revision': True,
|
||||
'model_attachment_id': self.model_attachment_id.id,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': new_rev.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('model_attachment_id')
|
||||
def _onchange_model_attachment_id(self):
|
||||
"""Auto-calculate surface area when a 3D model is attached."""
|
||||
if self.model_attachment_id:
|
||||
self._compute_surface_area_from_model()
|
||||
|
||||
def action_calculate_surface_area(self):
|
||||
"""Button: calculate surface area from the uploaded 3D model file."""
|
||||
self.ensure_one()
|
||||
if not self.model_attachment_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_('No 3D model file uploaded.'))
|
||||
result = self._compute_surface_area_from_model()
|
||||
if result.get('error'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(result['error'])
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Surface Area Calculated'),
|
||||
'message': result.get('message', 'Done'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def _compute_surface_area_from_model(self):
|
||||
"""Calculate surface area from the 3D model attachment.
|
||||
|
||||
Uses OCC (OpenCASCADE) for STEP/IGES/BREP files (exact B-Rep area).
|
||||
Falls back to trimesh for STL files (mesh-based area).
|
||||
Returns dict with result or error.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.model_attachment_id:
|
||||
return {'error': 'No 3D model file attached.'}
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
import os
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
raw = base64.b64decode(self.model_attachment_id.datas)
|
||||
fname = (self.model_attachment_id.name or '').lower()
|
||||
ext = os.path.splitext(fname)[1]
|
||||
|
||||
area_mm2 = 0.0
|
||||
volume_mm3 = 0.0
|
||||
bbox_dims = None
|
||||
method = 'unknown'
|
||||
|
||||
is_manifold = None
|
||||
hole_count = 0
|
||||
hole_summary = ''
|
||||
|
||||
if ext in ('.step', '.stp', '.iges', '.igs', '.brep', '.brp'):
|
||||
# OCC (OpenCASCADE) for CAD formats -- exact B-Rep area
|
||||
try:
|
||||
from OCP.STEPControl import STEPControl_Reader
|
||||
from OCP.IGESControl import IGESControl_Reader
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.BRepCheck import BRepCheck_Analyzer
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
||||
tmp.write(raw)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
if ext in ('.step', '.stp'):
|
||||
reader = STEPControl_Reader()
|
||||
else:
|
||||
reader = IGESControl_Reader()
|
||||
reader.ReadFile(tmp_path)
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
|
||||
# Surface area (B-Rep exact)
|
||||
props = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(shape, props)
|
||||
area_mm2 = props.Mass()
|
||||
|
||||
# Volume
|
||||
vol_props = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(shape, vol_props)
|
||||
volume_mm3 = vol_props.Mass()
|
||||
|
||||
# Bounding box
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin)
|
||||
|
||||
# Manifold check (watertight)
|
||||
try:
|
||||
analyzer = BRepCheck_Analyzer(shape)
|
||||
is_manifold = bool(analyzer.IsValid())
|
||||
except Exception:
|
||||
is_manifold = None
|
||||
|
||||
# Hole detection: group cylindrical faces by axis LINE + radius,
|
||||
# sum U-parameter ranges. Faces summing to ≥350° form a full
|
||||
# cylinder = real hole or boss. Partial cylinders are fillets/
|
||||
# rounded edges and are excluded.
|
||||
try:
|
||||
import math
|
||||
groups = {} # key -> [total_u_radians, radius]
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
try:
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() == GeomAbs_Cylinder:
|
||||
cyl = surf.Cylinder()
|
||||
axis = cyl.Axis()
|
||||
loc = axis.Location()
|
||||
d = axis.Direction()
|
||||
u_range = abs(
|
||||
surf.LastUParameter() - surf.FirstUParameter()
|
||||
)
|
||||
r = cyl.Radius()
|
||||
# Canonical axis point: project loc onto plane through origin
|
||||
# perpendicular to direction (unique per axis line)
|
||||
dx, dy, dz = d.X(), d.Y(), d.Z()
|
||||
lx, ly, lz = loc.X(), loc.Y(), loc.Z()
|
||||
t = lx * dx + ly * dy + lz * dz
|
||||
cx = lx - t * dx
|
||||
cy = ly - t * dy
|
||||
cz = lz - t * dz
|
||||
# Normalize direction sign (avoid +/- dedup)
|
||||
if dz < 0 or (dz == 0 and dy < 0) or (
|
||||
dz == 0 and dy == 0 and dx < 0
|
||||
):
|
||||
dx, dy, dz = -dx, -dy, -dz
|
||||
key = (
|
||||
round(r, 1),
|
||||
round(cx, 1), round(cy, 1), round(cz, 1),
|
||||
round(dx, 2), round(dy, 2), round(dz, 2),
|
||||
)
|
||||
if key in groups:
|
||||
groups[key][0] += u_range
|
||||
else:
|
||||
groups[key] = [u_range, r]
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
|
||||
# Keep only full cylinders (≥ 350° covered by grouped faces)
|
||||
from collections import Counter
|
||||
hole_diameters = []
|
||||
for (total_u, r) in groups.values():
|
||||
if math.degrees(total_u) >= 350:
|
||||
hole_diameters.append(round(r * 2, 1))
|
||||
if hole_diameters:
|
||||
counts = Counter(hole_diameters)
|
||||
hole_count = sum(counts.values())
|
||||
parts = ['%d× Ø%.1fmm' % (n, d)
|
||||
for d, n in sorted(counts.items())]
|
||||
hole_summary = ', '.join(parts)
|
||||
except Exception as he:
|
||||
_logger.warning('Hole detection failed: %s', he)
|
||||
|
||||
method = 'occ_brep'
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
except ImportError:
|
||||
return {'error': 'OCC (cadquery) not installed. Cannot process STEP/IGES files.'}
|
||||
except Exception as e:
|
||||
_logger.warning('OCC surface area calculation failed: %s', e)
|
||||
return {'error': f'OCC error: {e}'}
|
||||
|
||||
elif ext == '.stl':
|
||||
# trimesh for STL files
|
||||
try:
|
||||
import trimesh
|
||||
import io
|
||||
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
|
||||
area_mm2 = mesh.area
|
||||
volume_mm3 = mesh.volume
|
||||
bbox_dims = tuple(float(x) for x in mesh.bounding_box.extents)
|
||||
method = 'trimesh_mesh'
|
||||
except ImportError:
|
||||
return {'error': 'trimesh not installed. Cannot process STL files.'}
|
||||
except Exception as e:
|
||||
_logger.warning('trimesh surface area calculation failed: %s', e)
|
||||
return {'error': f'trimesh error: {e}'}
|
||||
else:
|
||||
return {'error': f'Unsupported file format: {ext}'}
|
||||
|
||||
area_sqin = area_mm2 / 645.16
|
||||
self.surface_area = round(area_sqin, 4)
|
||||
self.surface_area_uom = 'sq_in'
|
||||
self.geometry_source = '3d_model'
|
||||
|
||||
# Store extracted geometry on part catalog (triggers computed fields)
|
||||
self.volume_mm3 = round(volume_mm3, 2)
|
||||
if bbox_dims:
|
||||
self.bbox_length_mm = round(bbox_dims[0], 2)
|
||||
self.bbox_width_mm = round(bbox_dims[1], 2)
|
||||
self.bbox_height_mm = round(bbox_dims[2], 2)
|
||||
if is_manifold is not None:
|
||||
self.is_manifold = is_manifold
|
||||
self.hole_count = hole_count
|
||||
self.hole_summary = hole_summary
|
||||
|
||||
msg = '%.4f sq in (%.2f mm\u00b2) via %s' % (area_sqin, area_mm2, method)
|
||||
if hole_count:
|
||||
msg += ' | %d holes' % hole_count
|
||||
_logger.info('Part %s: %s', self.name, msg)
|
||||
return {
|
||||
'message': msg,
|
||||
'area_sqin': area_sqin,
|
||||
'area_mm2': area_mm2,
|
||||
'volume_mm3': volume_mm3,
|
||||
'bbox': bbox_dims,
|
||||
'is_manifold': is_manifold,
|
||||
'hole_count': hole_count,
|
||||
'hole_summary': hole_summary,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- 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 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.')
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
||||
'Only one surcharge per complexity level per rule.'),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- 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 = fields.Many2one('fp.coating.config', string='Coating Config',
|
||||
help='Leave blank for a global rule.')
|
||||
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',
|
||||
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')
|
||||
@@ -0,0 +1,828 @@
|
||||
# -*- 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 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', 'Confirmed'), ('cancelled', 'Cancelled')],
|
||||
string='Status', default='draft', tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True,
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
)
|
||||
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')
|
||||
def _compute_material_weight_kg(self):
|
||||
"""Compute weight from part volume × THIS QUOTE'S substrate density."""
|
||||
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 or not rec.substrate_material:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
density = density_map.get(rec.substrate_material, 7.85)
|
||||
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')
|
||||
|
||||
coating_config_id = fields.Many2one(
|
||||
'fp.coating.config', string='Coating Configuration', required=True,
|
||||
)
|
||||
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',
|
||||
)
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate', default='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',
|
||||
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
|
||||
self.substrate_material = cat.substrate_material
|
||||
# Copy masking area too (for effective-area calculation)
|
||||
self.masking_area_sqin = cat.masking_area_sqin
|
||||
|
||||
@api.onchange('coating_config_id')
|
||||
def _onchange_coating_config_id(self):
|
||||
if self.coating_config_id:
|
||||
self.thickness_requested = self.coating_config_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',
|
||||
'coating_config_id', 'coating_config_id.certification_level',
|
||||
)
|
||||
def _compute_price(self):
|
||||
for rec in self:
|
||||
if not rec.coating_config_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.
|
||||
"""
|
||||
rules = self.env['fp.pricing.rule'].search(
|
||||
[('active', '=', True)], order='sequence, id'
|
||||
)
|
||||
cert_level = (
|
||||
self.coating_config_id.certification_level
|
||||
if self.coating_config_id else False
|
||||
)
|
||||
|
||||
best = None
|
||||
best_score = -1
|
||||
for rule in rules:
|
||||
score = 0
|
||||
if rule.coating_config_id:
|
||||
if rule.coating_config_id != self.coating_config_id:
|
||||
continue
|
||||
score += 4
|
||||
if rule.substrate_material:
|
||||
if rule.substrate_material != self.substrate_material:
|
||||
continue
|
||||
score += 2
|
||||
if rule.certification_level:
|
||||
if rule.certification_level != cert_level:
|
||||
continue
|
||||
score += 1
|
||||
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_create_quotation(self):
|
||||
"""Create a sale.order from this configurator session."""
|
||||
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,
|
||||
})
|
||||
|
||||
coating_name = self.coating_config_id.name if self.coating_config_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_coating_config_id': self.coating_config_id.id,
|
||||
'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),
|
||||
'origin': self.name,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': '%s — %s (x%d)' % (coating_name, part_name, self.quantity),
|
||||
'product_uom_qty': self.quantity,
|
||||
'price_unit': price / self.quantity if self.quantity else price,
|
||||
})],
|
||||
}
|
||||
so = self.env['sale.order'].create(so_vals)
|
||||
self.write({
|
||||
'sale_order_id': so.id,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
self.message_post(
|
||||
body=_('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
|
||||
all_revs = self.env['fp.part.catalog'].search([
|
||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||
])
|
||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
||||
new_part = old_part.copy({
|
||||
'revision_number': max_rev + 1,
|
||||
'revision': f'Rev {max_rev + 1}',
|
||||
'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=_('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=_('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'})
|
||||
|
||||
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: substrate_material, 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.'))
|
||||
self.part_catalog_id.write({
|
||||
'substrate_material': self.substrate_material,
|
||||
'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,
|
||||
})
|
||||
self.message_post(
|
||||
body=_('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',
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- 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 FpTreatment(models.Model):
|
||||
"""Pre- or post-treatment step (bead blast, zincate, bake, passivate, etc.).
|
||||
|
||||
Used by coating configurations to specify which preparation and
|
||||
finishing steps are required for a given process.
|
||||
"""
|
||||
_name = 'fp.treatment'
|
||||
_description = 'Fusion Plating — Treatment'
|
||||
_order = 'treatment_type, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Treatment',
|
||||
required=True,
|
||||
help='e.g. "Bead Blast", "Zincate", "Hydrogen Embrittlement Bake"',
|
||||
)
|
||||
treatment_type = fields.Selection(
|
||||
[('pre', 'Pre-Treatment'), ('post', 'Post-Treatment')],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='pre',
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
default_duration_minutes = fields.Float(
|
||||
string='Default Duration (min)',
|
||||
help='Estimated duration per application in minutes.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
default_cost = fields.Monetary(
|
||||
string='Default Cost',
|
||||
currency_field='currency_id',
|
||||
help='Default cost per application. Can be overridden on pricing rules.',
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_treatment_name_type_uniq', 'unique(name, treatment_type)',
|
||||
'Treatment name must be unique per type.'),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- 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',
|
||||
)
|
||||
|
||||
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',
|
||||
}
|
||||
106
fusion_plating/fusion_plating_configurator/models/sale_order.py
Normal file
106
fusion_plating/fusion_plating_configurator/models/sale_order.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# -*- 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 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 = fields.Many2one('fp.coating.config', string='Coating Configuration')
|
||||
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')
|
||||
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_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
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'), ('inspected', 'Inspected')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
)
|
||||
|
||||
@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 ''),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user