This commit is contained in:
gsinghpal
2026-04-14 08:05:56 -04:00
parent d3c8782505
commit b62d4b1f36
15 changed files with 1272 additions and 30 deletions

View File

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

View File

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

View File

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