This commit is contained in:
gsinghpal
2026-04-13 02:35:35 -04:00
parent 1176ba68ae
commit 0ff8c0b93f
116 changed files with 14227 additions and 2406 deletions

View File

@@ -59,43 +59,190 @@ class FpPartCatalog(models.Model):
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
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 _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',
}
@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):
"""Calculate surface area from the uploaded 3D model file."""
"""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.'))
try:
import trimesh
except ImportError:
result = self._compute_surface_area_from_model()
if result.get('error'):
from odoo.exceptions import UserError
raise UserError(_('trimesh library not installed on the server. Contact your administrator.'))
import base64
import io
raw = base64.b64decode(self.model_attachment_id.datas)
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
area_mm2 = mesh.area
area_sqin = area_mm2 / 645.16
self.surface_area = round(area_sqin, 4)
self.surface_area_uom = 'sq_in'
self.geometry_source = '3d_model'
raise UserError(result['error'])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Surface Area Calculated'),
'message': _('%.4f sq in (%.2f mm\u00b2) from %d faces') % (area_sqin, area_mm2, len(mesh.faces)),
'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'
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
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()
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(shape, props)
area_mm2 = props.Mass()
vol_props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, vol_props)
volume_mm3 = vol_props.Mass()
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin)
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'
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}

View File

@@ -35,6 +35,25 @@ class FpQuoteConfigurator(models.Model):
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,
)
# -- 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')
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
)
@@ -350,5 +369,126 @@ class FpQuoteConfigurator(models.Model):
'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
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
if self.part_catalog_id:
# Update existing part
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:
# Create new part catalog 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
# Calculate surface area
part._compute_surface_area_from_model()
self.surface_area = part.surface_area
self.surface_area_uom = part.surface_area_uom
# 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.drawing_attachment_ids = [(4, att.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
self.upload_drawing = False
self.upload_drawing_filename = False
def action_recalculate_price(self):
"""Recalculate surface area from 3D model and recompute price."""
self.ensure_one()
# 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
# Price recomputes automatically via _compute_price dependency
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 new browser tab (full screen)."""
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',
}
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',
}