First Commit

This commit is contained in:
2026-05-31 10:17:09 +07:00
commit 17a9c69379
4547 changed files with 1170384 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
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,
}
)