changes
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user