First Commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from . import ifc_project
|
||||
from . import ifc_bom
|
||||
@@ -0,0 +1,20 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class IfcBom(models.Model):
|
||||
_name = 'grt.ifc.bom'
|
||||
_description = 'IFC Bill of Materials'
|
||||
_order = 'level, parent_id, sequence, id'
|
||||
|
||||
project_id = fields.Many2one('grt.ifc.project', string='Project', required=True, ondelete='cascade')
|
||||
ifc_key = fields.Char(string='IFC Key', required=True, index=True)
|
||||
name = fields.Char(string='Name', required=True)
|
||||
global_id = fields.Char(string='GlobalId', index=True)
|
||||
ifc_type = fields.Char(string='IFC Type', required=True, default='IfcProduct', index=True)
|
||||
parent_id = fields.Many2one('grt.ifc.bom', string='Parent Item', ondelete='cascade', index=True)
|
||||
child_ids = fields.One2many('grt.ifc.bom', 'parent_id', string='Child Items')
|
||||
relation_type = fields.Char(string='Relation Type', readonly=True)
|
||||
level = fields.Integer(string='Level', default=0, readonly=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
quantity = fields.Float(string='Quantity', digits=(16, 4), default=1.0)
|
||||
unit_name = fields.Char(string='Unit', default='ea')
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user