First Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'GRT IfcOpenShell Integration',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Construction/BIM',
|
||||
'summary': 'Integrasi Odoo dengan library IfcOpenShell untuk file IFC',
|
||||
'description': """
|
||||
Modul ini digunakan untuk memproses dan mengelola file IFC (Industry Foundation Classes)
|
||||
menggunakan library IfcOpenShell di Odoo 19.
|
||||
""",
|
||||
'author': 'GRT',
|
||||
'maintainer': 'GRT',
|
||||
'depends': ['base'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/ifc_project_menu.xml',
|
||||
'views/ifc_project_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_grt_ifc_project_user,access.grt.ifc.project.user,model_grt_ifc_project,base.group_user,1,1,1,1
|
||||
access_grt_ifc_bom_user,access.grt.ifc.bom.user,model_grt_ifc_bom,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem
|
||||
id="menu_grt_ifc_root"
|
||||
name="IFC"
|
||||
sequence="80"
|
||||
groups="base.group_user"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_grt_ifc_project"
|
||||
name="IFC Projects"
|
||||
parent="menu_grt_ifc_root"
|
||||
action="action_grt_ifc_project"
|
||||
sequence="10"
|
||||
groups="base.group_user"
|
||||
/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_grt_ifc_project_tree" model="ir.ui.view">
|
||||
<field name="name">grt.ifc.project.tree</field>
|
||||
<field name="model">grt.ifc.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IFC Projects">
|
||||
<field name="name"/>
|
||||
<field name="company_id"/>
|
||||
<field name="file_name"/>
|
||||
<field name="schema_name"/>
|
||||
<field name="entity_count"/>
|
||||
<field name="product_count" optional="show"/>
|
||||
<field name="assembly_count" optional="show"/>
|
||||
<field name="part_count" optional="hide"/>
|
||||
<field name="fastener_count" optional="hide"/>
|
||||
<field name="state"/>
|
||||
<field name="parsed_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_grt_ifc_project_form" model="ir.ui.view">
|
||||
<field name="name">grt.ifc.project.form</field>
|
||||
<field name="model">grt.ifc.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IFC Project">
|
||||
<header>
|
||||
<button name="action_parse_ifc"
|
||||
string="Parse IFC"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state == 'parsed'"/>
|
||||
<button name="action_load_sample_ifc"
|
||||
string="Load Sample"
|
||||
type="object"
|
||||
class="btn-secondary"/>
|
||||
<button name="action_reset_parse"
|
||||
string="Reset"
|
||||
type="object"
|
||||
invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,parsed,failed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="sample_file_key"/>
|
||||
<field name="file_name"/>
|
||||
<field name="file_data" filename="file_name"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="schema_name" readonly="1"/>
|
||||
<field name="entity_count" readonly="1"/>
|
||||
<field name="parsed_at" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Parse Log">
|
||||
<field name="parse_message" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="Product Engineering & Manufacturing Data">
|
||||
<field name="product_count"/>
|
||||
<field name="assembly_count"/>
|
||||
<field name="part_count"/>
|
||||
<field name="fastener_count"/>
|
||||
<field name="manufacturing_info" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Bill of Materials" name="bom_page" invisible="state != 'parsed'">
|
||||
<field name="bom_ids" readonly="1">
|
||||
<list string="BOM">
|
||||
<field name="level"/>
|
||||
<field name="parent_id" optional="hide"/>
|
||||
<field name="name"/>
|
||||
<field name="global_id"/>
|
||||
<field name="ifc_type" widget="badge"/>
|
||||
<field name="relation_type" optional="show"/>
|
||||
<field name="quantity" optional="show"/>
|
||||
<field name="unit_name" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_grt_ifc_project" model="ir.actions.act_window">
|
||||
<field name="name">IFC Projects</field>
|
||||
<field name="res_model">grt.ifc.project</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Upload file IFC pertama Anda</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user