folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# -*- 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 controllers
from . import models

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
Fusion Plating — Configurator
==============================
Part of the Fusion Plating product family by Nexa Systems Inc.
Provides:
- Customer part catalog with geometry and material data
- Coating configuration templates (process, thickness, spec)
- Pre/post treatment library
- Formula-based pricing engine with complexity surcharges
- Configurator sessions that generate sale orders
- Custom sale order views with plating-specific fields
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'sale_management',
'fusion_pdf_preview',
],
'data': [
'security/fp_configurator_security.xml',
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_treatment_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/res_partner_views.xml',
'views/fp_configurator_menu.xml',
],
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
'fusion_plating_configurator/static/src/xml/fp_drawing_preview.xml',
'fusion_plating_configurator/static/src/js/fp_drawing_preview.js',
'fusion_plating_configurator/static/src/xml/fp_pdf_inline_preview.xml',
'fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import configurator_controller

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import io
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpConfiguratorController(http.Controller):
@http.route('/fp/3d-viewer', type='http', auth='user', website=False)
def viewer_3d(self, **kw):
"""Serve the standalone 3D viewer HTML page.
Query params: id (attachment ID), name (filename for format detection).
The HTML page loads Online3DViewer and renders the model.
"""
from odoo.modules.module import get_module_path
import os
mod_path = get_module_path('fusion_plating_configurator')
html_path = os.path.join(
mod_path, 'static', 'src', 'html', '3d_viewer.html',
)
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
return request.make_response(content, headers=[
('Content-Type', 'text/html; charset=utf-8'),
])
@http.route('/fp/3d-model/<int:attachment_id>/<string:filename>',
type='http', auth='user', website=False)
def serve_3d_model(self, attachment_id, filename, **kw):
"""Serve a 3D model file from ir.attachment.
This bypasses the /web/content auth issues when loading inside
an iframe. The filename in the URL ensures Online3DViewer can
detect the format from the extension.
"""
attachment = request.env['ir.attachment'].browse(attachment_id)
if not attachment.exists():
return request.not_found()
raw = base64.b64decode(attachment.datas)
# Map common CAD extensions to MIME types
mime_map = {
'.step': 'application/step', '.stp': 'application/step',
'.iges': 'application/iges', '.igs': 'application/iges',
'.stl': 'application/sla',
'.brep': 'application/octet-stream', '.brp': 'application/octet-stream',
'.obj': 'text/plain', '.gltf': 'model/gltf+json', '.glb': 'model/gltf-binary',
}
import os
ext = os.path.splitext(filename)[1].lower()
content_type = mime_map.get(ext, 'application/octet-stream')
return request.make_response(raw, headers=[
('Content-Type', content_type),
('Content-Disposition', f'inline; filename="{filename}"'),
('Content-Length', str(len(raw))),
])
@http.route('/fp/configurator/calculate_surface_area', type='jsonrpc', auth='user')
def calculate_surface_area(self, attachment_id, **kw):
"""Calculate surface area from an uploaded STL file using trimesh."""
attachment = request.env['ir.attachment'].browse(int(attachment_id))
if not attachment.exists():
return {'error': 'Attachment not found.'}
try:
import trimesh
except ImportError:
return {'error': 'trimesh library not installed. Run: pip install trimesh'}
try:
raw = base64.b64decode(attachment.datas)
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
# trimesh returns area in the file's native units (usually mm²)
area_mm2 = mesh.area
area_sqin = area_mm2 / 645.16 # mm² to sq in
return {
'surface_area': round(area_sqin, 4),
'surface_area_mm2': round(area_mm2, 2),
'unit': 'sq_in',
'vertex_count': len(mesh.vertices),
'face_count': len(mesh.faces),
'bounding_box': {
'x': round(float(mesh.bounding_box.extents[0]), 2),
'y': round(float(mesh.bounding_box.extents[1]), 2),
'z': round(float(mesh.bounding_box.extents[2]), 2),
},
}
except Exception as e:
_logger.warning('STL surface area calculation failed: %s', e)
return {'error': str(e)}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo noupdate="1">
<record id="seq_fp_quote_configurator" model="ir.sequence">
<field name="name">Fusion Plating: Configurator</field>
<field name="code">fp.quote.configurator</field>
<field name="prefix">CFG-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo noupdate="1">
<!-- Pre-treatments -->
<record id="treatment_alkaline_clean" model="fp.treatment">
<field name="name">Alkaline Clean</field>
<field name="treatment_type">pre</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">15</field>
</record>
<record id="treatment_acid_etch" model="fp.treatment">
<field name="name">Acid Etch</field>
<field name="treatment_type">pre</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">10</field>
</record>
<record id="treatment_zincate" model="fp.treatment">
<field name="name">Zincate (Aluminium)</field>
<field name="treatment_type">pre</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">5</field>
</record>
<record id="treatment_bead_blast" model="fp.treatment">
<field name="name">Bead Blast</field>
<field name="treatment_type">pre</field>
<field name="sequence">40</field>
<field name="default_duration_minutes">20</field>
</record>
<record id="treatment_degrease" model="fp.treatment">
<field name="name">Solvent Degrease</field>
<field name="treatment_type">pre</field>
<field name="sequence">50</field>
<field name="default_duration_minutes">10</field>
</record>
<!-- Post-treatments -->
<record id="treatment_bake" model="fp.treatment">
<field name="name">Hydrogen Embrittlement Bake</field>
<field name="treatment_type">post</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">240</field>
</record>
<record id="treatment_passivate" model="fp.treatment">
<field name="name">Passivate</field>
<field name="treatment_type">post</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">30</field>
</record>
<record id="treatment_chromate_seal" model="fp.treatment">
<field name="name">Chromate Seal</field>
<field name="treatment_type">post</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">15</field>
</record>
</odoo>

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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.'),
]

View File

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

View File

@@ -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',
}

View File

@@ -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.'),
]

View File

@@ -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',
}

View 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 ''),
},
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="group_fp_estimator" model="res.groups">
<field name="name">Estimator</field>
<field name="sequence">50</field>
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
<field name="implied_ids" eval="[(4, ref('fusion_plating.group_fusion_plating_supervisor'))]"/>
</record>
<record id="group_fp_shop_manager" model="res.groups">
<field name="name">Shop Manager</field>
<field name="sequence">60</field>
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
<field name="implied_ids" eval="[
(4, ref('fusion_plating.group_fusion_plating_manager')),
(4, ref('group_fp_estimator')),
]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_treatment_operator,fp.treatment.operator,model_fp_treatment,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_treatment_supervisor,fp.treatment.supervisor,model_fp_treatment,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
access_fp_treatment_manager,fp.treatment.manager,model_fp_treatment,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_config_operator,fp.coating.config.operator,model_fp_coating_config,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_coating_config_estimator,fp.coating.config.estimator,model_fp_coating_config,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_config_manager,fp.coating.config.manager,model_fp_coating_config,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_treatment_operator fp.treatment.operator model_fp_treatment fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_treatment_supervisor fp.treatment.supervisor model_fp_treatment fusion_plating.group_fusion_plating_supervisor 1 1 0 0
4 access_fp_treatment_manager fp.treatment.manager model_fp_treatment fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_part_catalog_operator fp.part.catalog.operator model_fp_part_catalog fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_part_catalog_estimator fp.part.catalog.estimator model_fp_part_catalog fusion_plating_configurator.group_fp_estimator 1 1 1 0
7 access_fp_part_catalog_manager fp.part.catalog.manager model_fp_part_catalog fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_coating_config_operator fp.coating.config.operator model_fp_coating_config fusion_plating.group_fusion_plating_operator 1 0 0 0
9 access_fp_coating_config_estimator fp.coating.config.estimator model_fp_coating_config fusion_plating_configurator.group_fp_estimator 1 1 1 0
10 access_fp_coating_config_manager fp.coating.config.manager model_fp_coating_config fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_pricing_rule_operator fp.pricing.rule.operator model_fp_pricing_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
12 access_fp_pricing_rule_estimator fp.pricing.rule.estimator model_fp_pricing_rule fusion_plating_configurator.group_fp_estimator 1 1 1 0
13 access_fp_pricing_rule_manager fp.pricing.rule.manager model_fp_pricing_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_pricing_surcharge_operator fp.pricing.complexity.surcharge.operator model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_operator 1 0 0 0
15 access_fp_pricing_surcharge_estimator fp.pricing.complexity.surcharge.estimator model_fp_pricing_complexity_surcharge fusion_plating_configurator.group_fp_estimator 1 1 1 0
16 access_fp_pricing_surcharge_manager fp.pricing.complexity.surcharge.manager model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_quote_configurator_operator fp.quote.configurator.operator model_fp_quote_configurator fusion_plating.group_fusion_plating_operator 1 0 0 0
18 access_fp_quote_configurator_estimator fp.quote.configurator.estimator model_fp_quote_configurator fusion_plating_configurator.group_fp_estimator 1 1 1 0
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,13 @@
importScripts ('occt-import-js.js');
onmessage = async function (ev)
{
let modulOverrides = {
locateFile: function (path) {
return path;
}
};
let occt = await occtimportjs (modulOverrides);
let result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);
postMessage (result);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>3D Part Viewer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5}
#viewer-container{width:100%;height:100%}
#loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#6c757d;z-index:100}
#loading .spinner{width:44px;height:44px;border:3px solid #dee2e6;border-top-color:#0d6efd;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
@keyframes spin{to{transform:rotate(360deg)}}
#error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:20px 28px;color:#664d03;max-width:80%;text-align:center;font-size:13px;z-index:100;display:none}
#format-badge{position:absolute;top:10px;right:10px;font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;z-index:100;backdrop-filter:blur(4px)}
.fmt-step{background:rgba(33,150,243,.15);color:#1565c0}
.fmt-iges{background:rgba(156,39,176,.15);color:#7b1fa2}
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
#toolbar{position:absolute;top:10px;left:10px;display:flex;gap:4px;z-index:100;flex-wrap:wrap;background:rgba(255,255,255,.85);padding:4px;border-radius:6px;backdrop-filter:blur(4px);box-shadow:0 1px 3px rgba(0,0,0,.1)}
#toolbar button{background:#fff;border:1px solid #ced4da;border-radius:4px;padding:4px 8px;font-size:11px;font-weight:500;cursor:pointer;color:#495057;transition:all .15s;min-width:40px}
#toolbar button:hover{background:#0d6efd;color:#fff;border-color:#0d6efd}
#toolbar .btn-divider{width:1px;background:#dee2e6;margin:2px 4px}
</style>
</head>
<body>
<div id="viewer-container"></div>
<div id="toolbar">
<button onclick="setView('top')" title="Top view">Top</button>
<button onclick="setView('bottom')" title="Bottom view">Btm</button>
<button onclick="setView('front')" title="Front view">Front</button>
<button onclick="setView('back')" title="Back view">Back</button>
<button onclick="setView('left')" title="Left view">Left</button>
<button onclick="setView('right')" title="Right view">Right</button>
<button onclick="setView('iso')" title="Isometric view">Iso</button>
<span class="btn-divider"></span>
<button onclick="fitToView()" title="Fit to view">Fit</button>
<button onclick="takeScreenshot()" title="Take screenshot (PNG)">📷</button>
</div>
<div id="format-badge"></div>
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
<div id="error"></div>
<script src="/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js"></script>
<script>
(function() {
const container = document.getElementById('viewer-container');
const loadingEl = document.getElementById('loading');
const loadingMsg = document.getElementById('loading-msg');
const errorEl = document.getElementById('error');
const fmtBadge = document.getElementById('format-badge');
const params = new URLSearchParams(window.location.search);
const attachmentId = params.get('id');
const fileName = params.get('name') || 'model.stl';
function detectFormat(name) {
if (!name) return 'other';
const n = name.toLowerCase();
if (n.match(/\.(step|stp)$/)) return 'step';
if (n.match(/\.(iges|igs)$/)) return 'iges';
if (n.match(/\.(brep|brp)$/)) return 'brep';
if (n.match(/\.stl$/)) return 'stl';
if (n.match(/\.(obj)$/)) return 'other';
if (n.match(/\.(gltf|glb)$/)) return 'other';
if (n.match(/\.(3ds|fbx|dae|3mf|ply|off|wrl|3dm)$/)) return 'other';
return 'other';
}
function showFormat(fmt) {
fmtBadge.className = 'fmt-' + fmt;
fmtBadge.textContent = fmt.toUpperCase();
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
if (!attachmentId) {
showError('No model specified (missing ?id= parameter)');
return;
}
showFormat(detectFormat(fileName));
// Initialize the embedded viewer
// Note: v0.18.0 loads WASM (occt-import-js) from CDN automatically
const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(240, 242, 245, 255),
defaultColor: new OV.RGBColor(33, 150, 243),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
});
// Fetch the file ourselves (with session credentials) then load as blob
loadingMsg.textContent = 'Downloading ' + fileName + '...';
const modelUrl = '/fp/3d-model/' + attachmentId + '/' + encodeURIComponent(fileName);
fetch(modelUrl, { credentials: 'same-origin' })
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
return resp.arrayBuffer();
})
.then(function(buffer) {
loadingMsg.textContent = 'Parsing ' + fileName + '...';
// Create a File object so O3DV can detect format from the name
var file = new File([buffer], fileName, { type: 'application/octet-stream' });
viewer.LoadModelFromFileList([file]);
// Poll for completion
var checkCount = 0;
var checkInterval = setInterval(function() {
checkCount++;
try {
var model = viewer.GetModel();
if (model && model.MeshCount() > 0) {
loadingEl.style.display = 'none';
clearInterval(checkInterval);
}
} catch(e) {}
if (checkCount > 600) {
clearInterval(checkInterval);
if (loadingEl.style.display !== 'none') {
showError('Timeout parsing model. STEP files may take a minute on first load (WASM engine init).');
}
}
}, 100);
})
.catch(function(err) {
showError('Failed to load model: ' + err.message);
});
// ---- View preset functions (Top/Front/Side/Iso) ----
// Online3DViewer's internal viewer exposes a Camera object we can manipulate.
window.setView = function(view) {
try {
const v = viewer.GetViewer();
if (!v) return;
const camera = v.GetCamera();
if (!camera) return;
// Compute distance from current camera to keep zoom roughly consistent
const eye = camera.eye;
const center = camera.center;
const dx = eye.x - center.x, dy = eye.y - center.y, dz = eye.z - center.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 100;
let newEye, newUp;
switch (view) {
case 'top':
newEye = new OV.Coord3D(center.x, center.y, center.z + dist);
newUp = new OV.Coord3D(0, 1, 0);
break;
case 'bottom':
newEye = new OV.Coord3D(center.x, center.y, center.z - dist);
newUp = new OV.Coord3D(0, 1, 0);
break;
case 'front':
newEye = new OV.Coord3D(center.x, center.y - dist, center.z);
newUp = new OV.Coord3D(0, 0, 1);
break;
case 'back':
newEye = new OV.Coord3D(center.x, center.y + dist, center.z);
newUp = new OV.Coord3D(0, 0, 1);
break;
case 'left':
newEye = new OV.Coord3D(center.x - dist, center.y, center.z);
newUp = new OV.Coord3D(0, 0, 1);
break;
case 'right':
newEye = new OV.Coord3D(center.x + dist, center.y, center.z);
newUp = new OV.Coord3D(0, 0, 1);
break;
case 'iso':
default:
const d = dist / Math.sqrt(3);
newEye = new OV.Coord3D(center.x + d, center.y - d, center.z + d);
newUp = new OV.Coord3D(0, 0, 1);
break;
}
const newCam = new OV.Camera(newEye, center, newUp, camera.fov);
v.SetCamera(newCam);
v.Render();
} catch(e) {
console.warn('setView failed:', e);
}
};
window.fitToView = function() {
try {
const v = viewer.GetViewer();
if (v && v.FitSphereToWindow) {
// FitSphereToWindow uses the model's bounding sphere
v.FitSphereToWindow(v.GetBoundingSphere(() => true), false);
v.Render();
}
} catch(e) {
console.warn('fitToView failed:', e);
}
};
window.takeScreenshot = function() {
try {
const v = viewer.GetViewer();
if (!v) return;
// Get the renderer's canvas and convert to PNG
const canvas = v.GetCanvas ? v.GetCanvas() : null;
if (!canvas) {
// Fallback: find canvas inside container
const c = container.querySelector('canvas');
if (!c) return;
downloadCanvas(c);
return;
}
downloadCanvas(canvas);
} catch(e) {
console.warn('screenshot failed:', e);
}
};
function downloadCanvas(canvas) {
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
a.download = (fileName.replace(/\.[^.]+$/, '') || 'model') + '-' + stamp + '.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 'image/png');
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
/** @odoo-module **/
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Simple OWL field widget that embeds the standalone 3D viewer page
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Dialog } from "@web/core/dialog/dialog";
export class Fp3dViewer extends Component {
static template = "fusion_plating_configurator.Fp3dViewer";
static props = { ...standardFieldProps };
setup() {
this.state = useState({ hasAttachment: false, iframeSrc: "" });
this._updateState();
}
get rawValue() {
return this.props.record.data[this.props.name];
}
get attachmentId() {
const v = this.rawValue;
if (!v) return 0;
if (Array.isArray(v)) return v[0] || 0;
if (typeof v === "object" && v.id) return v.id;
return typeof v === "number" ? v : 0;
}
get attachmentName() {
const v = this.rawValue;
if (!v) return "";
if (Array.isArray(v)) return v[1] || "";
if (typeof v === "object" && v.display_name) return v.display_name;
return "";
}
_updateState() {
const aid = this.attachmentId;
this.state.hasAttachment = !!aid;
if (aid) {
const name = encodeURIComponent(this.attachmentName);
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
}
}
onPatched() {
this._updateState();
}
}
registry.category("fields").add("fp_3d_preview", {
component: Fp3dViewer,
supportedTypes: ["many2one"],
});
// =============================================================================
// 3D Viewer Dialog component (full-screen embedded viewer)
// =============================================================================
export class Fp3dViewerDialog extends Component {
static template = "fusion_plating_configurator.Fp3dViewerDialog";
static components = { Dialog };
static props = {
attachmentId: Number,
name: { type: String, optional: true },
close: { type: Function, optional: true },
};
setup() {
this.state = useState({ isMaximized: true });
}
get iframeSrc() {
const name = encodeURIComponent(this.props.name || "");
return `/fp/3d-viewer?id=${this.props.attachmentId}&name=${name}`;
}
get dialogSize() {
return this.state.isMaximized ? "fullscreen" : "xl";
}
get frameStyle() {
if (this.state.isMaximized) {
return "height: calc(98vh - 100px) !important;";
}
return "height: calc(85vh - 100px) !important;";
}
toggleSize() {
this.state.isMaximized = !this.state.isMaximized;
}
}
registry.category("dialog").add("Fp3dViewerDialog", Fp3dViewerDialog);
// Client action handler — opens the 3D viewer in a dialog within the same window.
// Triggered by Python returning:
// { type: 'ir.actions.client', tag: 'fp_3d_viewer_open',
// params: { attachment_id: N, name: "..." } }
function fp3dViewerOpenAction(env, action) {
const params = action.params || {};
if (!params.attachment_id) return Promise.resolve();
env.services.dialog.add(Fp3dViewerDialog, {
attachmentId: params.attachment_id,
name: params.name || "",
});
return Promise.resolve();
}
registry.category("actions").add("fp_3d_viewer_open", fp3dViewerOpenAction);

View File

@@ -0,0 +1,81 @@
/** @odoo-module **/
// Fusion Plating -- PDF Drawing Preview Widget
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Custom many2many_binary widget that opens PDFs in the fusion_pdf_preview
// dialog instead of downloading them.
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import {
Many2ManyBinaryField,
many2ManyBinaryField,
} from "@web/views/fields/many2many_binary/many2many_binary_field";
export class FpPdfPreviewBinary extends Many2ManyBinaryField {
static template = "fusion_plating_configurator.FpPdfPreviewBinary";
setup() {
super.setup();
this.dialogService = useService("dialog");
}
onFileClick(ev, file) {
const isPdf = (file.mimetype === "application/pdf") ||
(file.name || "").toLowerCase().endsWith(".pdf");
const dialogs = registry.category("dialog");
if (isPdf && dialogs.contains("PDFViewerDialog")) {
ev.preventDefault();
ev.stopPropagation();
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
const url = `/web/content/${file.id}?download=false`;
this.dialogService.add(PDFViewerDialog, {
url: url,
title: file.name || "Drawing",
reportName: "",
recordIds: "",
modelName: "ir.attachment",
});
}
// For non-PDF or when preview not available, default browser behavior
// (the <a href> with download attribute) kicks in because we don't
// prevent default.
}
}
export const fpPdfPreviewBinary = {
...many2ManyBinaryField,
component: FpPdfPreviewBinary,
};
registry.category("fields").add("fp_pdf_preview_binary", fpPdfPreviewBinary);
// Client action handler: open a PDF attachment in the fusion_pdf_preview dialog.
// Triggered by Python methods returning:
// { type: 'ir.actions.client', tag: 'fp_pdf_preview_open',
// params: { attachment_id: N, title: "..." } }
function fpPdfPreviewOpenAction(env, action) {
const params = action.params || {};
const attId = params.attachment_id;
if (!attId) return Promise.resolve();
const dialogs = registry.category("dialog");
const PDFViewerDialog = dialogs.contains("PDFViewerDialog") ? dialogs.get("PDFViewerDialog") : null;
if (!PDFViewerDialog) {
window.open(`/web/content/${attId}?download=false`, '_blank');
return Promise.resolve();
}
const url = `/web/content/${attId}?download=false`;
env.services.dialog.add(PDFViewerDialog, {
url: url,
title: params.title || 'Document',
reportName: '',
recordIds: '',
modelName: 'ir.attachment',
});
return Promise.resolve();
}
registry.category("actions").add("fp_pdf_preview_open", fpPdfPreviewOpenAction);

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
// Fusion Plating -- Inline PDF Preview field widget
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Field widget for Many2one(ir.attachment) fields that embeds the
// PDF.js viewer inline at a fixed height (one page at a time).
// A "Full Screen" button below opens the fusion_pdf_preview dialog.
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class FpPdfInlinePreview extends Component {
static template = "fusion_plating_configurator.FpPdfInlinePreview";
static props = { ...standardFieldProps };
setup() {
this.dialogService = useService("dialog");
this.state = useState({ hasAttachment: false, iframeSrc: "", attId: 0, name: "" });
this._updateState();
}
get rawValue() {
return this.props.record.data[this.props.name];
}
_updateState() {
const v = this.rawValue;
let attId = 0;
let name = "";
if (v) {
if (Array.isArray(v)) {
attId = v[0] || 0;
name = v[1] || "";
} else if (typeof v === "object" && v.id) {
attId = v.id;
name = v.display_name || "";
} else if (typeof v === "number") {
attId = v;
}
}
this.state.hasAttachment = !!attId;
this.state.attId = attId;
this.state.name = name;
if (attId) {
const fileUrl = `/web/content/${attId}?download=false`;
// PDF.js URL params: zoom=page-fit, no thumbs sidebar, single-page mode
this.state.iframeSrc =
`/fusion_pdf_preview/static/lib/pdfjs/web/viewer.html` +
`?file=${encodeURIComponent(fileUrl)}` +
`#zoom=page-fit&pagemode=none&scrollmode=3`;
}
}
onPatched() {
this._updateState();
}
openFullScreen() {
if (!this.state.attId) return;
const dialogs = registry.category("dialog");
if (!dialogs.contains("PDFViewerDialog")) return;
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
const url = `/web/content/${this.state.attId}?download=false`;
this.dialogService.add(PDFViewerDialog, {
url: url,
title: this.state.name || "Drawing",
reportName: "",
recordIds: "",
modelName: "ir.attachment",
});
}
}
registry.category("fields").add("fp_pdf_inline_preview", {
component: FpPdfInlinePreview,
supportedTypes: ["many2one"],
});

View File

@@ -0,0 +1,143 @@
// =============================================================================
// Fusion Plating -- 3D Viewer + Configurator Layout
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
// =============================================================================
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
// When the preview column is hidden (no 3D model AND no drawings), the
// fields column expands to full width via the :has() selector below.
.o_fp_cfg_layout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
align-items: start;
}
// Full width when right column has no visible content
.o_fp_cfg_layout:has(> .o_fp_cfg_preview.o_invisible_modifier),
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display: none"]),
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display:none"]) {
grid-template-columns: 1fr;
}
.o_fp_cfg_fields {
min-width: 0;
}
.o_fp_cfg_preview {
position: sticky;
top: 16px;
// Force all field widgets (3D viewer, Html drawing preview) to be
// block-level + full width so the 3D and PDF iframes match exactly.
.o_field_widget,
> div > .o_field_widget {
display: block;
width: 100%;
}
iframe {
display: block;
}
}
// Responsive: stack on narrow screens
@media (max-width: 1200px) {
.o_fp_cfg_layout {
grid-template-columns: 1fr;
}
.o_fp_cfg_preview {
position: static;
}
}
// -- 3D viewer widget --
.o_fp_3d_viewer_root {
width: 100%;
}
.o_fp_3d_placeholder {
border: 2px dashed $border-color;
border-radius: 0.5rem;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--bs-secondary-color);
background-color: var(--bs-tertiary-bg);
}
.o_fp_3d_iframe {
width: 100%;
height: 450px;
border: 1px solid $border-color;
border-radius: 0.5rem;
background-color: #f0f2f5;
display: block;
}
// Inside the preview column: same height as the PDF preview iframe
.o_fp_cfg_preview .o_fp_3d_iframe {
height: 450px;
}
// -- 3D Viewer Dialog (full-screen modal) --
.o_fp_3d_dialog {
.modal-body {
padding: 0;
}
}
.o_fp_3d_dialog_body {
width: 100%;
background-color: #f0f2f5;
overflow: hidden;
}
.o_fp_3d_dialog_iframe {
width: 100%;
border: 0;
display: block;
background-color: #f0f2f5;
}
.o_fp_3d_dialog_actions {
padding: 8px 12px;
text-align: right;
border-top: 1px solid var(--bs-border-color, #dee2e6);
background-color: var(--bs-body-bg);
}
// -- Inline PDF preview widget (fp_pdf_inline_preview) --
.o_fp_pdf_inline_root {
width: 100%;
}
.o_fp_pdf_inline_frame_wrap {
width: 100%;
height: 450px;
border: 1px solid $border-color;
border-radius: 0.5rem;
overflow: hidden;
background-color: #f0f2f5;
}
.o_fp_pdf_inline_iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.o_fp_pdf_inline_placeholder {
border: 2px dashed $border-color;
border-radius: 0.5rem;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bs-tertiary-bg);
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.Fp3dViewer">
<div class="o_fp_3d_viewer_root">
<t t-if="!state.hasAttachment">
<div class="o_fp_3d_placeholder text-center text-muted p-4">
<i class="fa fa-cube fa-3x mb-2 d-block"/>
<span>Upload a 3D model (STL, STEP, IGES) to preview it here.</span>
</div>
</t>
<t t-if="state.hasAttachment">
<iframe t-att-src="state.iframeSrc"
class="o_fp_3d_iframe"
frameborder="0"
allowfullscreen="true"/>
</t>
</div>
</t>
<t t-name="fusion_plating_configurator.Fp3dViewerDialog">
<Dialog title.translate="3D Model Viewer"
size="dialogSize"
contentClass="'o_fp_3d_dialog'"
footer="false">
<div class="o_fp_3d_dialog_body">
<iframe t-att-src="iframeSrc"
t-att-style="frameStyle"
class="o_fp_3d_dialog_iframe"
frameborder="0"
allowfullscreen="true"/>
</div>
<div class="o_fp_3d_dialog_actions">
<button type="button"
class="btn btn-sm btn-outline-secondary"
t-on-click="toggleSize">
<i t-att-class="state.isMaximized ? 'fa fa-compress me-1' : 'fa fa-expand me-1'"/>
<t t-if="state.isMaximized">Restore</t>
<t t-else="">Maximize</t>
</button>
<button type="button"
class="btn btn-sm btn-secondary ms-2"
t-on-click="props.close">
Close
</button>
</div>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpPdfPreviewBinary">
<div t-attf-class="oe_fileupload {{props.className ? props.className : ''}}" aria-atomic="true">
<div class="o_attachments">
<t t-foreach="files" t-as="file" t-key="file_index">
<t t-set="editable" t-value="!props.readonly"/>
<t t-set="ext" t-value="getExtension(file)"/>
<t t-set="url" t-value="getUrl(file.id)"/>
<t t-set="isPdf" t-value="(file.mimetype === 'application/pdf') or (file.name and file.name.toLowerCase().endsWith('.pdf'))"/>
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' }"
t-att-title="file.name">
<div class="o_attachment_wrap">
<div class="o_image_box float-start"
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name">
<a t-att-href="url"
t-on-click="(ev) => this.onFileClick(ev, file)"
t-att-download="isPdf ? undefined : ''"
aria-label="Open">
<img t-if="isImage(file)"
class="o_preview_image o_hover object-fit-cover rounded align-baseline"
t-attf-src="/web/image/{{ file.id }}"
onerror="this.src = '/web/static/img/mimetypes/image.svg'"/>
<span t-else="" class="o_image o_preview_image o_hover"
t-att-data-mimetype="file.mimetype"
t-att-data-ext="ext" role="img"/>
</a>
</div>
<div class="caption">
<a class="ml4"
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name"
t-att-href="url"
t-on-click="(ev) => this.onFileClick(ev, file)"
t-att-download="isPdf ? undefined : ''"><t t-esc="file.name"/></a>
</div>
<div class="caption small">
<a class="ml4 small text-uppercase"
t-att-href="url"
t-on-click="(ev) => this.onFileClick(ev, file)"
t-att-download="isPdf ? undefined : ''">
<b><t t-esc="ext"/></b>
</a>
</div>
<div class="o_attachment_uploaded">
<i class="text-success fa fa-check" role="img"
aria-label="Uploaded" title="Uploaded"/>
</div>
<div t-if="editable" class="o_attachment_delete"
t-on-click.stop="() => this.onFileRemove(file.id)">
<span role="img" aria-label="Delete" title="Delete">×</span>
</div>
</div>
</div>
</t>
</div>
<div t-if="!props.readonly and (!props.numberOfFiles or files.length &lt; props.numberOfFiles)"
class="oe_add">
<FileInput acceptedFileExtensions="props.acceptedFileExtensions"
multiUpload="true"
onUpload.bind="onFileUploaded"
resModel="props.record.resModel"
resId="props.record.resId or 0">
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
<span class="fa fa-paperclip" aria-label="Attach"/>
<t t-esc="uploadText"/>
</button>
</FileInput>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.FpPdfInlinePreview">
<div class="o_fp_pdf_inline_root">
<t t-if="state.hasAttachment">
<div class="o_fp_pdf_inline_frame_wrap">
<iframe t-att-src="state.iframeSrc"
class="o_fp_pdf_inline_iframe"
frameborder="0"
title="PDF Preview"/>
</div>
<div class="text-center mt-2">
<button type="button"
class="btn btn-sm btn-outline-primary"
t-on-click="openFullScreen">
<i class="fa fa-expand me-1"/>Full Screen
</button>
</div>
</t>
<t t-if="!state.hasAttachment">
<div class="o_fp_pdf_inline_placeholder text-center text-muted p-4">
<i class="fa fa-file-pdf-o fa-3x mb-2 d-block"/>
<span>No PDF attached.</span>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Coating Configuration List View ===== -->
<record id="view_fp_coating_config_list" model="ir.ui.view">
<field name="name">fp.coating.config.list</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<list string="Coating Configurations" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="process_type_id"/>
<field name="phosphorus_level"/>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="spec_reference"/>
<field name="certification_level"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Coating Configuration Form View ===== -->
<record id="view_fp_coating_config_form" model="ir.ui.view">
<field name="name">fp.coating.config.form</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<form string="Coating Configuration">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos AMS 2404"/></h1>
</div>
<group>
<group>
<field name="process_type_id"/>
<field name="recipe_id"/>
<field name="phosphorus_level"/>
<field name="certification_level"/>
<field name="sequence"/>
</group>
<group>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="thickness_uom"/>
<field name="spec_reference"/>
</group>
</group>
<notebook>
<page string="Treatments" name="treatments">
<group>
<group string="Pre-Treatments">
<field name="pre_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Post-Treatments">
<field name="post_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Detailed description of this coating configuration..."/>
</page>
</notebook>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Coating Configuration Search View ===== -->
<record id="view_fp_coating_config_search" model="ir.ui.view">
<field name="name">fp.coating.config.search</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="process_type_id"/>
<field name="spec_reference"/>
<separator/>
<filter string="Commercial" name="commercial" domain="[('certification_level','=','commercial')]"/>
<filter string="Mil-Spec" name="mil_spec" domain="[('certification_level','=','mil_spec')]"/>
<filter string="Nadcap" name="nadcap" domain="[('certification_level','=','nadcap')]"/>
<filter string="Nuclear" name="nuclear" domain="[('certification_level','=','nuclear')]"/>
<separator/>
<filter string="Low Phosphorus" name="low_phos" domain="[('phosphorus_level','=','low_phos')]"/>
<filter string="Mid Phosphorus" name="mid_phos" domain="[('phosphorus_level','=','mid_phos')]"/>
<filter string="High Phosphorus" name="high_phos" domain="[('phosphorus_level','=','high_phos')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Process Type" name="group_process_type" context="{'group_by':'process_type_id'}"/>
<filter string="Certification Level" name="group_cert_level" context="{'group_by':'certification_level'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_coating_config" model="ir.actions.act_window">
<field name="name">Coating Configurations</field>
<field name="res_model">fp.coating.config</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_config_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No coating configurations defined yet
</p>
<p>
Define coating setups with process type, phosphorus level,
thickness range, spec reference, and required treatments.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Window actions (must be defined before menus reference them) ===== -->
<record id="action_fp_customers" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('customer_rank', '>', 0)]</field>
<field name="context">{'default_customer_rank': 1}</field>
</record>
<!-- ===== SALES submenu under Fusion Plating root ===== -->
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="5"
groups="group_fp_estimator,fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
parent="menu_fp_sales"
action="action_fp_quotations"
sequence="10"/>
<menuitem id="menu_fp_sale_orders"
name="Sale Orders"
parent="menu_fp_sales"
action="action_fp_sale_orders"
sequence="20"/>
<menuitem id="menu_fp_customers"
name="Customers"
parent="menu_fp_sales"
action="action_fp_customers"
sequence="30"/>
<menuitem id="menu_fp_part_catalog"
name="Part Catalog"
parent="menu_fp_sales"
action="action_fp_part_catalog"
sequence="40"/>
<!-- ===== CONFIGURATOR submenu ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="8"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_new_quote"
name="New Quote"
parent="menu_fp_configurator"
action="action_fp_quote_configurator"
sequence="10"/>
<menuitem id="menu_fp_coating_configs"
name="Coating Configurations"
parent="menu_fp_configurator"
action="action_fp_coating_config"
sequence="20"/>
<menuitem id="menu_fp_pricing_rules"
name="Pricing Rules"
parent="menu_fp_configurator"
action="action_fp_pricing_rule"
sequence="30"/>
<menuitem id="menu_fp_treatments"
name="Treatments"
parent="menu_fp_configurator"
action="action_fp_treatment"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,214 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Part Catalog List View ===== -->
<record id="view_fp_part_catalog_list" model="ir.ui.view">
<field name="name">fp.part.catalog.list</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<list string="Part Catalog" decoration-muted="not active">
<field name="partner_id"/>
<field name="part_number"/>
<field name="revision"/>
<field name="substrate_material"/>
<field name="surface_area"/>
<field name="complexity"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Part Catalog Form View ===== -->
<record id="view_fp_part_catalog_form" model="ir.ui.view">
<field name="name">fp.part.catalog.form</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<form string="Part Catalog">
<header>
<button name="action_create_revision"
string="Create New Revision"
type="object"
class="btn-secondary"
icon="fa-code-fork"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_orders"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="sale_order_count == 0">
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
</button>
<button name="action_view_configurators"
type="object"
class="oe_stat_button"
icon="fa-sliders"
invisible="configurator_count == 0">
<field name="configurator_count" widget="statinfo" string="Quotes"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<widget name="web_ribbon" title="Superseded" bg_color="text-bg-warning" invisible="is_latest_revision"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Valve Body Housing"/></h1>
<field name="part_number" placeholder="Customer part number (e.g. VS-R392007E01)"/>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="revision"/>
<field name="revision_number"/>
<field name="substrate_material"/>
<field name="geometry_source"/>
<field name="is_latest_revision" invisible="1"/>
<field name="parent_part_id" invisible="not parent_part_id"/>
</group>
<group>
<label for="surface_area"/>
<div class="d-flex align-items-center gap-2">
<field name="surface_area" class="oe_inline"/>
<button name="action_calculate_surface_area" type="object"
string="Calculate from 3D Model"
class="btn-link" icon="fa-calculator"
invisible="not model_attachment_id"/>
</div>
<field name="surface_area_uom"/>
<field name="masking_area_sqin"/>
<field name="effective_area_sqin" readonly="1"/>
<field name="weight"/>
<field name="material_weight_kg" readonly="1"/>
</group>
</group>
<!-- Auto-extracted geometry from 3D model -->
<group string="3D Model Analysis"
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
<group>
<field name="bbox_summary_in" readonly="1"/>
<field name="volume_mm3" readonly="1"/>
<field name="bbox_length_mm" invisible="1"/>
<field name="bbox_width_mm" invisible="1"/>
<field name="bbox_height_mm" invisible="1"/>
</group>
<group>
<field name="hole_count" readonly="1"/>
<field name="hole_summary" readonly="1" invisible="not hole_summary"/>
<field name="is_manifold" widget="boolean_toggle" readonly="1"/>
</group>
<div class="alert alert-warning mb-0"
colspan="2"
invisible="is_manifold or not model_attachment_id">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Geometry warning:</strong> 3D model is not watertight (manifold).
This often indicates open shells or invalid surfaces. Review before quoting.
</div>
</group>
<notebook>
<page string="Dimensions &amp; Complexity" name="dimensions">
<group>
<group>
<field name="dimensions_length"/>
<field name="dimensions_width"/>
<field name="dimensions_height"/>
</group>
<group>
<field name="complexity"/>
<field name="masking_zones"/>
<field name="has_blind_holes"/>
<field name="has_recesses"/>
<field name="has_threads"/>
</group>
</group>
<group>
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
</group>
</page>
<page string="Attachments" name="attachments">
<group>
<field name="model_attachment_id"/>
<field name="drawing_attachment_ids" widget="fp_pdf_preview_binary"/>
</group>
<div invisible="not model_attachment_id" class="mt-3">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
</div>
</page>
<page string="Revision History" name="revisions"
invisible="not parent_part_id and not revision_ids">
<field name="revision_ids" mode="list">
<list default_order="revision_number desc">
<field name="revision_number" string="Rev #"/>
<field name="revision"/>
<field name="revision_note"/>
<field name="revision_date"/>
<field name="surface_area"/>
<field name="model_attachment_id" string="3D Model"/>
<field name="is_latest_revision" widget="boolean_toggle"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Part Catalog Search View ===== -->
<record id="view_fp_part_catalog_search" model="ir.ui.view">
<field name="name">fp.part.catalog.search</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="part_number"/>
<field name="partner_id"/>
<separator/>
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
<filter string="Stainless Steel" name="material_stainless" domain="[('substrate_material','=','stainless')]"/>
<filter string="Copper" name="material_copper" domain="[('substrate_material','=','copper')]"/>
<filter string="Titanium" name="material_titanium" domain="[('substrate_material','=','titanium')]"/>
<filter string="Other" name="material_other" domain="[('substrate_material','=','other')]"/>
<separator/>
<filter string="Simple" name="complexity_simple" domain="[('complexity','=','simple')]"/>
<filter string="Moderate" name="complexity_moderate" domain="[('complexity','=','moderate')]"/>
<filter string="Complex" name="complexity_complex" domain="[('complexity','=','complex')]"/>
<filter string="Very Complex" name="complexity_very_complex" domain="[('complexity','=','very_complex')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
<filter string="Material" name="group_material" context="{'group_by':'substrate_material'}"/>
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_part_catalog" model="ir.actions.act_window">
<field name="name">Part Catalog</field>
<field name="res_model">fp.part.catalog</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_part_catalog_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No parts in the catalog yet
</p>
<p>
Add customer parts with geometry, material, and complexity data
for instant re-quoting on repeat orders.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Pricing Rule List View ===== -->
<record id="view_fp_pricing_rule_list" model="ir.ui.view">
<field name="name">fp.pricing.rule.list</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<list string="Pricing Rules" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
<field name="pricing_method"/>
<field name="currency_id" column_invisible="1"/>
<field name="base_rate"/>
<field name="minimum_charge"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Pricing Rule Form View ===== -->
<record id="view_fp_pricing_rule_form" model="ir.ui.view">
<field name="name">fp.pricing.rule.form</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<form string="Pricing Rule">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos Aluminium — Commercial"/></h1>
</div>
<group string="Filters">
<group>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
</group>
<group>
<div class="text-muted" colspan="2">
Leave filter fields blank to create a global rule
that matches any configuration.
</div>
</group>
</group>
<group string="Pricing">
<group>
<field name="pricing_method"/>
<field name="currency_id" invisible="1"/>
<field name="base_rate"/>
<field name="thickness_factor"/>
</group>
<group>
<field name="masking_rate_per_zone"/>
<field name="setup_fee"/>
<field name="minimum_charge"/>
<field name="rush_surcharge_percent"/>
</group>
</group>
<notebook>
<page string="Complexity Surcharges" name="surcharges">
<field name="complexity_surcharge_ids">
<list editable="bottom">
<field name="complexity"/>
<field name="surcharge_percent"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this pricing rule..."/>
</page>
</notebook>
<group>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Pricing Rule Search View ===== -->
<record id="view_fp_pricing_rule_search" model="ir.ui.view">
<field name="name">fp.pricing.rule.search</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
<filter string="Per Piece" name="per_piece" domain="[('pricing_method','=','per_piece')]"/>
<filter string="Flat Rate" name="flat_rate" domain="[('pricing_method','=','flat_rate')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Coating Config" name="group_coating_config" context="{'group_by':'coating_config_id'}"/>
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_pricing_rule" model="ir.actions.act_window">
<field name="name">Pricing Rules</field>
<field name="res_model">fp.pricing.rule</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_pricing_rule_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No pricing rules defined yet
</p>
<p>
Define formula-based pricing rules matched by coating
configuration, substrate material, and certification level.
The first matching rule (by sequence) wins.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,326 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Configurator Form View ===== -->
<record id="view_fp_quote_configurator_form" model="ir.ui.view">
<field name="name">fp.quote.configurator.form</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<form string="Quote Configurator">
<header>
<button name="action_create_quotation"
string="Create Quotation"
type="object"
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
<button name="action_recalculate_price"
string="Recalculate"
type="object"
class="btn-secondary"/>
<button name="action_save_to_catalog"
string="Save to Catalog"
type="object"
class="btn-secondary"
confirm="This will overwrite the part catalog's geometry, substrate, masking area, and complexity with values from this quote. Continue?"
invisible="not part_catalog_id"/>
<button name="action_cancel"
string="Cancel"
type="object"
invisible="state == 'cancelled'"/>
<button name="action_reset_draft"
string="Reset to Draft"
type="object"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="not sale_order_id">
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
</button>
<button name="action_view_part_catalog"
type="object"
class="oe_stat_button"
icon="fa-cube"
invisible="not part_catalog_id">
<field name="part_catalog_id" widget="statinfo" string="Part"/>
</button>
<button name="action_view_drawings"
type="object"
class="oe_stat_button"
icon="fa-file-pdf-o"
invisible="drawing_count == 0">
<field name="drawing_count" widget="statinfo" string="Drawings"/>
</button>
<button name="action_view_rfq"
type="object"
class="oe_stat_button"
icon="fa-envelope-o"
invisible="not rfq_attachment_id">
<div class="o_stat_info">
<span class="o_stat_value">1</span>
<span class="o_stat_text">RFQ</span>
</div>
</button>
<button name="action_view_po"
type="object"
class="oe_stat_button"
icon="fa-file-o"
invisible="not po_attachment_id">
<div class="o_stat_info">
<span class="o_stat_value">1</span>
<span class="o_stat_text">PO</span>
</div>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Main layout: 3/4 fields (left) + 1/4 3D preview (right) -->
<div class="o_fp_cfg_layout">
<!-- LEFT COLUMN: all fields -->
<div class="o_fp_cfg_fields">
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<!-- 3D File: upload before, filename + clear button after -->
<field name="upload_3d_file" filename="upload_3d_filename"
invisible="state != 'draft' or model_attachment_id"
string="Attach 3D File"/>
<field name="upload_3d_filename" invisible="1"/>
<field name="model_attachment_id"
string="3D Model"
invisible="not model_attachment_id"
readonly="state != 'draft'"/>
<!-- Drawing: upload before, filename + clear button after -->
<field name="upload_drawing" filename="upload_drawing_filename"
invisible="state != 'draft' or drawing_count > 0"
string="Attach Drawing"/>
<field name="upload_drawing_filename" invisible="1"/>
<field name="first_drawing_id"
string="Drawing"
invisible="drawing_count == 0"
readonly="state != 'draft'"/>
<field name="drawing_count" invisible="1"/>
</group>
<group string="RFQ / PO Documents">
<field name="upload_rfq_file"
filename="upload_rfq_filename"
invisible="state != 'draft' or rfq_attachment_id"
string="Attach RFQ"/>
<field name="upload_rfq_filename" invisible="1"/>
<field name="rfq_attachment_id"
invisible="not rfq_attachment_id"
readonly="state != 'draft'"/>
<field name="upload_po_file"
filename="upload_po_filename"
invisible="state != 'draft' or po_attachment_id"
string="Attach PO"/>
<field name="upload_po_filename" invisible="1"/>
<field name="po_attachment_id"
invisible="not po_attachment_id"
readonly="state != 'draft'"/>
<field name="po_number_preliminary"
string="PO Number"
readonly="state != 'draft'"/>
<separator string="Quantity &amp; Options"/>
<field name="quantity"/>
<field name="batch_size"/>
<field name="complexity"/>
<field name="rush_order"/>
</group>
</group>
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="masking_area_sqin"
string="Masking Area (sq in)"/>
<field name="effective_area_sqin"
string="Effective Plating Area"
readonly="1"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
<field name="masking_zones"/>
<field name="turnaround_days"/>
</group>
<group string="Auto from 3D"
invisible="not part_catalog_id">
<field name="bbox_summary_in"
string="Dimensions"
readonly="1"/>
<field name="material_weight_kg"
string="Weight (kg)"
readonly="1"/>
<field name="hole_count"
string="Holes"
readonly="1"/>
<field name="hole_summary"
readonly="1"
invisible="not hole_summary"/>
<field name="is_manifold"
widget="boolean_toggle"
readonly="1"/>
</group>
</group>
<div class="alert alert-warning"
invisible="is_manifold or not part_catalog_id or not hole_count">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Warning:</strong> 3D model is not watertight.
Surface area calculation may be inaccurate. Review the file before quoting.
</div>
<group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
</div>
<!-- RIGHT COLUMN: 3D preview + Drawings preview (sticky) -->
<div class="o_fp_cfg_preview"
invisible="not model_attachment_id and drawing_count == 0">
<!-- 3D viewer -->
<div invisible="not model_attachment_id">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
<div class="text-center mt-2">
<button name="action_open_3d_fullscreen"
string="Full Screen"
type="object"
class="btn btn-sm btn-outline-primary"
icon="fa-expand"/>
</div>
</div>
<!-- Drawings preview (custom OWL widget — fixed height, full screen button) -->
<div invisible="drawing_count == 0" class="mt-3">
<span class="o_form_label fw-bold text-muted small d-block mb-1">Drawing Preview</span>
<field name="first_drawing_id"
widget="fp_pdf_inline_preview"
nolabel="1"
readonly="1"/>
<!-- Multi-drawing list shown only when more than one -->
<div invisible="drawing_count &lt; 2" class="mt-2">
<span class="o_form_label fw-bold text-muted small d-block mb-1">All Drawings</span>
<field name="drawing_attachment_ids"
widget="fp_pdf_preview_binary"
nolabel="1"
readonly="1"/>
</div>
</div>
</div>
</div>
<notebook>
<page string="Sale Order" name="sale_order">
<group>
<field name="sale_order_id" readonly="1"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this quote..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Configurator List View ===== -->
<record id="view_fp_quote_configurator_list" model="ir.ui.view">
<field name="name">fp.quote.configurator.list</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<list string="Quote Configurators"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancelled'"
default_order="create_date desc">
<field name="create_date" string="Date"/>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="surface_area"/>
<field name="quantity"/>
<field name="currency_id" column_invisible="1"/>
<field name="calculated_price"/>
<field name="estimator_override_price" string="Final Price"/>
<field name="state" widget="badge"
decoration-success="state == 'confirmed'"
decoration-info="state == 'draft'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<!-- ===== Configurator Search View ===== -->
<record id="view_fp_quote_configurator_search" model="ir.ui.view">
<field name="name">fp.quote.configurator.search</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group>
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_quote_configurator" model="ir.actions.act_window">
<field name="name">Quote Configurator</field>
<field name="res_model">fp.quote.configurator</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quote configurator session
</p>
<p>
Select a customer and coating configuration, enter part geometry,
and the pricing engine will calculate a quote. The estimator can
override the calculated price before creating a sale order.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Treatment List View ===== -->
<record id="view_fp_treatment_list" model="ir.ui.view">
<field name="name">fp.treatment.list</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<list string="Treatments">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="treatment_type"/>
<field name="default_duration_minutes"/>
<field name="currency_id" column_invisible="1"/>
<field name="default_cost"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Treatment Form View ===== -->
<record id="view_fp_treatment_form" model="ir.ui.view">
<field name="name">fp.treatment.form</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<form string="Treatment">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Bead Blast"/></h1>
</div>
<group>
<group>
<field name="treatment_type"/>
<field name="sequence"/>
</group>
<group>
<field name="default_duration_minutes"/>
<field name="currency_id" invisible="1"/>
<field name="default_cost"/>
</group>
</group>
<group>
<field name="description" placeholder="Description of this treatment step..."/>
</group>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Treatment Search View ===== -->
<record id="view_fp_treatment_search" model="ir.ui.view">
<field name="name">fp.treatment.search</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Pre-Treatment" name="pre" domain="[('treatment_type','=','pre')]"/>
<filter string="Post-Treatment" name="post" domain="[('treatment_type','=','post')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by':'treatment_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_treatment" model="ir.actions.act_window">
<field name="name">Treatments</field>
<field name="res_model">fp.treatment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_treatment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No treatments defined yet
</p>
<p>
Add pre-treatment steps (bead blast, zincate, acid etch) and
post-treatment steps (bake, passivate, chromate seal).
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_fp_parts" model="ir.ui.view">
<field name="name">res.partner.form.fp.parts</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_parts"
type="object"
class="oe_stat_button"
icon="fa-cube"
invisible="x_fc_part_count == 0">
<field name="x_fc_part_count" widget="statinfo" string="Parts"/>
</button>
</xpath>
<xpath expr="//page[@name='sales_purchases']" position="after">
<page string="Part Library" name="part_library">
<field name="x_fc_part_catalog_ids" mode="list"
domain="[('is_latest_revision', '=', True)]">
<list default_order="name" >
<field name="part_number"/>
<field name="name"/>
<field name="revision"/>
<field name="revision_number" string="Rev #"/>
<field name="substrate_material"/>
<field name="surface_area"/>
<field name="surface_area_uom" string="UoM"/>
<field name="complexity"/>
<field name="model_attachment_id" string="3D Model"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Inherit SO Form — add Plating tab ===== -->
<record id="view_sale_order_form_fp" model="ir.ui.view">
<field name="name">sale.order.form.fp.configurator</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_rfq"
type="object"
class="oe_stat_button"
icon="fa-envelope-o"
invisible="not x_fc_rfq_attachment_id">
<div class="o_stat_info">
<span class="o_stat_value">1</span>
<span class="o_stat_text">RFQ</span>
</div>
</button>
<button name="action_view_po"
type="object"
class="oe_stat_button"
icon="fa-file-o"
invisible="not x_fc_po_attachment_id">
<div class="o_stat_info">
<span class="o_stat_value">1</span>
<span class="o_stat_text">PO</span>
</div>
</button>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part &amp; Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
</group>
<group string="RFQ / PO">
<field name="x_fc_po_number"/>
<field name="upload_rfq_file"
filename="upload_rfq_filename"
invisible="x_fc_rfq_attachment_id"
string="Attach RFQ"/>
<field name="upload_rfq_filename" invisible="1"/>
<field name="x_fc_rfq_attachment_id"
invisible="not x_fc_rfq_attachment_id"/>
<field name="upload_po_file"
filename="upload_po_filename"
invisible="x_fc_po_attachment_id"
string="Attach PO"/>
<field name="upload_po_filename" invisible="1"/>
<field name="x_fc_po_attachment_id"
invisible="not x_fc_po_attachment_id"/>
<field name="x_fc_po_received"/>
<field name="x_fc_po_override"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="x_fc_po_override_reason"
invisible="not x_fc_po_override"/>
</group>
</group>
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ===== Custom SO List View for Fusion Plating ===== -->
<record id="view_sale_order_list_fp" model="ir.ui.view">
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_receiving_status" widget="badge"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
<field name="x_fc_delivery_method" optional="show"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== Window Action — Quotations (for Fusion Plating menu) ===== -->
<record id="action_fp_quotations" model="ir.actions.act_window">
<field name="name">Quotations</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('draft', 'sent'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
<field name="context">{'default_x_fc_delivery_method': 'shipping_partner'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quotation
</p>
</field>
</record>
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="name">Sale Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('sale', 'done'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
</record>
</odoo>