import base64 import os import tempfile from collections import OrderedDict from pathlib import Path from odoo import _, api, fields, models, Command from odoo.exceptions import UserError class IfcProject(models.Model): _name = 'grt.ifc.project' _description = 'IFC Project' _order = 'id desc' name = fields.Char(required=True) company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, required=True, index=True, ) file_data = fields.Binary(string='IFC File', required=True, attachment=True) file_name = fields.Char(string='Filename') sample_file_key = fields.Selection( selection='_selection_sample_files', string='Sample IFC', help='Pilih sample IFC dari folder samples untuk pengujian cepat.', ) state = fields.Selection( [ ('draft', 'Draft'), ('parsed', 'Parsed'), ('failed', 'Failed'), ], default='draft', required=True, ) schema_name = fields.Char(readonly=True) entity_count = fields.Integer(readonly=True) parse_message = fields.Text(readonly=True) product_count = fields.Integer(string='IfcProduct Count', readonly=True) assembly_count = fields.Integer(string='IfcAssembly Count', readonly=True) part_count = fields.Integer(string='Part Count', readonly=True) fastener_count = fields.Integer(string='Fastener Count', readonly=True) manufacturing_info = fields.Text(string='Manufacturing Details', readonly=True) bom_ids = fields.One2many('grt.ifc.bom', 'project_id', string='Bill of Materials') parsed_at = fields.Datetime(readonly=True) @api.model def _get_ifcopenshell(self): try: import ifcopenshell # pylint: disable=import-outside-toplevel except ImportError as exc: raise UserError( _( 'Python package "ifcopenshell" belum terpasang di environment Odoo.' ) ) from exc return ifcopenshell @api.model def _samples_dir(self): return Path(__file__).resolve().parents[1] / 'samples' @api.model def _selection_sample_files(self): samples_dir = self._samples_dir() if not samples_dir.exists(): return [] return [ (sample.name, sample.name) for sample in sorted(samples_dir.glob('*.ifc')) if sample.is_file() ] def action_load_sample_ifc(self): for record in self: if not record.sample_file_key: raise UserError(_('Pilih sample IFC terlebih dahulu.')) sample_path = self._samples_dir() / record.sample_file_key if not sample_path.exists() or not sample_path.is_file(): raise UserError(_('File sample tidak ditemukan: %s') % record.sample_file_key) payload = sample_path.read_bytes() record.write( { 'name': record.name or sample_path.stem, 'file_name': sample_path.name, 'file_data': base64.b64encode(payload), } ) record.action_reset_parse() @api.model def _is_product_engineering_entity(self, entity): try: if not entity or not entity.is_a('IfcProduct'): return False if entity.is_a('IfcSpatialStructureElement') or entity.is_a('IfcProject'): return False return True except Exception: return False @api.model def _entity_key(self, entity): return getattr(entity, 'GlobalId', None) or f"#{entity.id()}" @api.model def _extract_quantity(self, entity): unit_by_value_attr = { 'CountValue': 'ea', 'LengthValue': 'm', 'AreaValue': 'm2', 'VolumeValue': 'm3', 'WeightValue': 'kg', 'TimeValue': 's', } try: for definition in getattr(entity, 'IsDefinedBy', []) or []: if not definition.is_a('IfcRelDefinesByProperties'): continue prop_def = getattr(definition, 'RelatingPropertyDefinition', None) if not prop_def or not prop_def.is_a('IfcElementQuantity'): continue for quantity in getattr(prop_def, 'Quantities', []) or []: for attr_name, unit_name in unit_by_value_attr.items(): if hasattr(quantity, attr_name): value = getattr(quantity, attr_name) if isinstance(value, (int, float)): return float(value), unit_name except Exception: pass return 1.0, 'ea' def action_parse_ifc(self): for record in self: if not record.file_data: raise UserError(_('Unggah file IFC terlebih dahulu.')) ifcopenshell = self._get_ifcopenshell() file_path = None try: with tempfile.NamedTemporaryFile( mode='wb', suffix='.ifc', delete=False ) as temp_ifc: temp_ifc.write(base64.b64decode(record.file_data)) file_path = temp_ifc.name ifc_model = ifcopenshell.open(file_path) def safe_by_type(model, entity_type): try: return model.by_type(entity_type) or [] except Exception: return [] entities = safe_by_type(ifc_model, 'IfcRoot') # --- Ekstraksi Entitas Manufaktur --- products = safe_by_type(ifc_model, 'IfcProduct') assemblies = safe_by_type(ifc_model, 'IfcElementAssembly') # IFC2X3 umumnya memakai IfcBuildingElementPart, beberapa varian lain memakai IfcPart. parts = safe_by_type(ifc_model, 'IfcBuildingElementPart') if not parts: parts = safe_by_type(ifc_model, 'IfcPart') # Fastener dapat muncul sebagai IfcMechanicalFastener (IFC4) atau IfcFastener. fasteners = safe_by_type(ifc_model, 'IfcMechanicalFastener') if not fasteners: fasteners = safe_by_type(ifc_model, 'IfcFastener') mfg_details = ( f"Identifikasi Manufaktur:\n" f"- Schema IFC: {getattr(ifc_model, 'schema', '-') or '-'}\n" f"- Total IfcProduct: {len(products)}\n" f"- Total Assembly (IfcElementAssembly): {len(assemblies)}\n" f"- Total Part: {len(parts)}\n" f"- Total Fastener: {len(fasteners)}\n" ) if assemblies: mfg_details += "\nDaftar Assembly Utama (Maksimal 10):\n" for asm in assemblies[:10]: mfg_details += f"- {asm.Name or 'Unnamed Assembly'} (GlobalId: {asm.GlobalId})\n" if len(assemblies) > 10: mfg_details += f"... dan {len(assemblies) - 10} assembly lainnya.\n" # --- Membuat Data BOM (Bills of Materials) --- # (5, 0, 0) digunakan untuk menghapus semua relasi lama sebelum mengganti dengan yang baru bom_data = [Command.clear()] bom_rows = OrderedDict() def add_bom_row(entity, default_name, relation_type='Direct', parent_key=None, sequence=10): key = self._entity_key(entity) if key in bom_rows: existing_parent = bom_rows[key].get('parent_key') if parent_key and not existing_parent: bom_rows[key]['parent_key'] = parent_key bom_rows[key]['relation_type'] = relation_type return qty_value, qty_unit = self._extract_quantity(entity) bom_rows[key] = { 'ifc_key': key, 'name': getattr(entity, 'Name', None) or default_name, 'global_id': getattr(entity, 'GlobalId', ''), 'ifc_type': entity.is_a(), 'quantity': qty_value, 'unit_name': qty_unit, 'relation_type': relation_type, 'parent_key': parent_key, 'sequence': sequence, } for asm in assemblies: add_bom_row(asm, 'Unnamed Assembly') for part in parts: add_bom_row(part, 'Unnamed Part') for fastener in fasteners: add_bom_row(fastener, 'Unnamed Fastener') relation_records = safe_by_type(ifc_model, 'IfcRelAggregates') + safe_by_type(ifc_model, 'IfcRelNests') for relation in relation_records: parent_entity = getattr(relation, 'RelatingObject', None) if not self._is_product_engineering_entity(parent_entity): continue parent_key = self._entity_key(parent_entity) add_bom_row(parent_entity, 'Unnamed Parent', relation_type=relation.is_a()) for idx, child_entity in enumerate(getattr(relation, 'RelatedObjects', []) or [], start=1): if not self._is_product_engineering_entity(child_entity): continue add_bom_row( child_entity, 'Unnamed Child', relation_type=relation.is_a(), parent_key=parent_key, sequence=idx * 10, ) # Fallback agar sample product engineering minimal tetap menghasilkan BOM. if not bom_rows: for product in products: if not self._is_product_engineering_entity(product): continue add_bom_row(product, 'Unnamed Product') level_cache = {} def get_level(row_key): if row_key in level_cache: return level_cache[row_key] row = bom_rows.get(row_key) if not row or not row.get('parent_key'): level_cache[row_key] = 0 return 0 parent_key = row.get('parent_key') if parent_key == row_key or parent_key not in bom_rows: level_cache[row_key] = 0 return 0 level_cache[row_key] = get_level(parent_key) + 1 return level_cache[row_key] for key in bom_rows: bom_rows[key]['level'] = get_level(key) for values in bom_rows.values(): bom_data.append( Command.create( { 'ifc_key': values['ifc_key'], 'name': values['name'], 'global_id': values['global_id'], 'ifc_type': values['ifc_type'], 'relation_type': values['relation_type'], 'level': values['level'], 'sequence': values['sequence'], 'quantity': values['quantity'], 'unit_name': values['unit_name'], } ) ) record.write( { 'schema_name': getattr(ifc_model, 'schema', None), 'entity_count': len(entities), 'product_count': len(products), 'assembly_count': len(assemblies), 'part_count': len(parts), 'fastener_count': len(fasteners), 'manufacturing_info': mfg_details, 'bom_ids': bom_data, 'parse_message': _('IFC berhasil diparsing.'), 'parsed_at': fields.Datetime.now(), 'state': 'parsed', } ) bom_by_key = {line.ifc_key: line for line in record.bom_ids} for row in bom_rows.values(): parent_key = row.get('parent_key') if not parent_key: continue child_line = bom_by_key.get(row['ifc_key']) parent_line = bom_by_key.get(parent_key) if child_line and parent_line and child_line.id != parent_line.id: child_line.parent_id = parent_line.id except Exception as exc: # pragma: no cover record.write( { 'state': 'failed', 'parse_message': str(exc), } ) raise UserError(_('Gagal memproses file IFC: %s') % exc) from exc finally: if file_path and os.path.exists(file_path): os.unlink(file_path) def action_reset_parse(self): self.write( { 'state': 'draft', 'schema_name': False, 'entity_count': 0, 'parse_message': False, 'product_count': 0, 'assembly_count': 0, 'part_count': 0, 'fastener_count': 0, 'manufacturing_info': False, 'bom_ids': [Command.clear()], 'parsed_at': False, } )