337 lines
14 KiB
Python
337 lines
14 KiB
Python
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,
|
|
}
|
|
) |