diff --git a/fusion-plating/fusion_plating_configurator/__manifest__.py b/fusion-plating/fusion_plating_configurator/__manifest__.py
index c78adbed..321ec1e2 100644
--- a/fusion-plating/fusion_plating_configurator/__manifest__.py
+++ b/fusion-plating/fusion_plating_configurator/__manifest__.py
@@ -32,6 +32,7 @@ Provides:
'depends': [
'fusion_plating',
'sale_management',
+ 'fusion_pdf_preview',
],
'data': [
'security/fp_configurator_security.xml',
@@ -52,6 +53,10 @@ Provides:
'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,
diff --git a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py
index 5d6e1a1d..6a34b2b0 100644
--- a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py
+++ b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py
@@ -46,7 +46,10 @@ class FpPartCatalog(models.Model):
[('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.')
+ 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',
)
@@ -68,9 +71,104 @@ class FpPartCatalog(models.Model):
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='Cylindrical Features',
+ help='Total cylindrical surfaces detected (includes holes, bores, '
+ 'and rounded fillets — see breakdown for diameter clustering).',
+ )
+ hole_summary = fields.Char(
+ string='Feature Diameters',
+ help='Cylindrical features grouped by diameter (rounded to 0.5mm). '
+ 'Small diameters are usually edge fillets; larger ones are typically holes.',
+ )
+ 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',
)
@@ -83,6 +181,77 @@ class FpPartCatalog(models.Model):
'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(_('3D model attached: %s') % new.name)
+ elif old and not new:
+ messages.append(_('3D model removed: %s') % old.name)
+ elif old and new and old.id != new.id:
+ messages.append(_('3D model changed: %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(_('Drawing attached: %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(_('Drawing removed: %s') % name)
+
+ if messages:
+ body = '
'.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 %s: %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(
@@ -215,6 +384,10 @@ class FpPartCatalog(models.Model):
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:
@@ -224,6 +397,12 @@ class FpPartCatalog(models.Model):
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)
@@ -238,18 +417,59 @@ class FpPartCatalog(models.Model):
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
+
+ # Cylindrical feature detection — counts holes, fillets, bores.
+ # NOTE: fillets/rounded edges also appear as cylinders. We
+ # cluster by diameter (rounded to 0.5 mm) and report the
+ # breakdown so the estimator can identify real holes vs fillets.
+ try:
+ diameters = []
+ 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()
+ diameter_mm = cyl.Radius() * 2.0
+ # Round to 0.5 mm clusters (fillets ~0.5-3mm, holes ~3mm+)
+ diameters.append(round(diameter_mm * 2) / 2)
+ except Exception:
+ pass
+ explorer.Next()
+ if diameters:
+ from collections import Counter
+ counts = Counter(diameters)
+ hole_count = sum(counts.values())
+ # Sort by diameter ascending, format
+ parts = ['%d× Ø%.1fmm' % (n, d)
+ for d, n in sorted(counts.items())]
+ hole_summary = ', '.join(parts)
+ except Exception as he:
+ _logger.warning('Cylindrical feature detection failed: %s', he)
+
method = 'occ_brep'
finally:
os.unlink(tmp_path)
@@ -283,7 +503,28 @@ class FpPartCatalog(models.Model):
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)
- _logger.info('Part %s: surface area = %s', self.name, msg)
- return {'message': msg, 'area_sqin': area_sqin, 'area_mm2': area_mm2,
- 'volume_mm3': volume_mm3, 'bbox': bbox_dims}
+ 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,
+ }
diff --git a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py
index 2e847ad6..e03dbdd9 100644
--- a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py
+++ b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py
@@ -40,6 +40,88 @@ class FpQuoteConfigurator(models.Model):
string='3D Model',
readonly=True,
)
+ drawing_attachment_ids = fields.Many2many(
+ related='part_catalog_id.drawing_attachment_ids',
+ string='Drawings',
+ readonly=True,
+ )
+ # -- Auto-extracted geometry from part catalog (read-only on configurator) --
+ bbox_summary_in = fields.Char(
+ related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
+ readonly=True,
+ )
+ material_weight_kg = fields.Float(
+ related='part_catalog_id.material_weight_kg', string='Weight (kg)',
+ 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,
+ )
+ masking_area_sqin = fields.Float(
+ related='part_catalog_id.masking_area_sqin', string='Masking Area (sq in)',
+ readonly=False, # allow editing via configurator
+ )
+ effective_area_sqin = fields.Float(
+ related='part_catalog_id.effective_area_sqin', string='Effective Area (sq in)',
+ readonly=True,
+ )
+ 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',
@@ -54,6 +136,27 @@ class FpQuoteConfigurator(models.Model):
)
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,
)
@@ -345,6 +448,11 @@ class FpQuoteConfigurator(models.Model):
'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,
@@ -430,6 +538,15 @@ class FpQuoteConfigurator(models.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: %s — 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
@@ -449,7 +566,10 @@ class FpQuoteConfigurator(models.Model):
})
if self.part_catalog_id:
- self.part_catalog_id.drawing_attachment_ids = [(4, att.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()
@@ -461,9 +581,74 @@ class FpQuoteConfigurator(models.Model):
})
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: %s (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()
@@ -495,16 +680,18 @@ class FpQuoteConfigurator(models.Model):
self.write({'state': 'draft'})
def action_open_3d_fullscreen(self):
- """Open the 3D model viewer in a new browser tab (full screen)."""
+ """Open the 3D model viewer in a full-screen dialog (same window)."""
self.ensure_one()
att = self.model_attachment_id
if not att:
return
- url = f'/fp/3d-viewer?id={att.id}&name={att.name}'
return {
- 'type': 'ir.actions.act_url',
- 'url': url,
- 'target': 'new',
+ 'type': 'ir.actions.client',
+ 'tag': 'fp_3d_viewer_open',
+ 'params': {
+ 'attachment_id': att.id,
+ 'name': att.name or '',
+ },
}
def action_view_sale_order(self):
@@ -526,3 +713,27 @@ class FpQuoteConfigurator(models.Model):
'view_mode': 'form',
'target': 'current',
}
+
+ 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',
+ }
diff --git a/fusion-plating/fusion_plating_configurator/models/sale_order.py b/fusion-plating/fusion_plating_configurator/models/sale_order.py
index c8479a2e..89a67163 100644
--- a/fusion-plating/fusion_plating_configurator/models/sale_order.py
+++ b/fusion-plating/fusion_plating_configurator/models/sale_order.py
@@ -13,8 +13,18 @@ class SaleOrder(models.Model):
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')
+ 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')
@@ -36,3 +46,61 @@ class SaleOrder(models.Model):
('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 ''),
+ },
+ }
diff --git a/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html b/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html
index 173b9aa4..ea98d3c4 100644
--- a/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html
+++ b/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html
@@ -18,10 +18,26 @@ html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-sy
.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}