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
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import controllers
+28
View File
@@ -0,0 +1,28 @@
{
'name': 'GRT Product Engineering',
'version': '19.0.1.0.0',
'category': 'Manufacturing',
'summary': 'Manajemen dokumen IFC, approval berlapis, dan estimasi BoM',
'description': """
Modul Product Engineering untuk Odoo 19:
- Manajemen dokumen desain IFC beserta riwayat revisi
- Validasi dan approval 2 layer (Manager Produksi, Manager Provisioning)
- Aktivitas pemeriksaan dan approval berbasis mail.activity
- Estimasi BoM dari hasil parsing IFC ke MRP BoM
""",
'author': 'GRT',
'maintainer': 'GRT',
'license': 'LGPL-3',
'depends': ['grt_ifcopenshell', 'mail', 'mrp', 'purchase', 'web'],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'views/ifc_project_views.xml',
'views/ifc_project_menu.xml',
'views/ifc_kpi_dashboard_views.xml',
'views/mrp_bom_views.xml',
],
'installable': True,
'application': False,
}
@@ -0,0 +1 @@
from . import ifc_jsonrpc
@@ -0,0 +1,888 @@
import base64
import datetime
import hashlib
import hmac
import json
import mimetypes
import tempfile
import time
import uuid
from pathlib import Path
from urllib.parse import urlencode
from odoo import http, _
from odoo.exceptions import AccessError, UserError
from odoo.http import request
TEMP_SUBDIR = 'odoo_ifc_temp'
TEMP_TTL_MINUTES = 60
def _json_http_response(payload, status=200):
return request.make_response(
json.dumps(payload),
headers=[('Content-Type', 'application/json')],
status=status,
)
def _jsonrpc_ok(result=None):
return {'success': True, 'result': result or {}}
def _jsonrpc_fail(message, code='ERROR', details=None):
payload = {'success': False, 'error': {'code': code, 'message': message}}
if details:
payload['error']['details'] = details
return payload
def _safe_filename(filename, fallback='document.ifc'):
name = (filename or fallback).strip().replace('\\', '_').replace('/', '_')
return name or fallback
def _to_bool(value, default=True):
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in ('1', 'true', 'yes', 'y', 'on')
class IfcJsonRpcController(http.Controller):
def _check_record_access(self, record, mode='read'):
record.check_access_rights(mode)
record.check_access_rule(mode)
def _serialize_project(self, project):
return {
'id': project.id,
'name': project.name,
'document_ref': project.document_ref,
'file_name': project.file_name,
'company_id': project.company_id.id if project.company_id else False,
'company_name': project.company_id.name if project.company_id else False,
'state': project.state,
'approval_state': project.approval_state,
'uploaded_date': project.uploaded_date.isoformat() if project.uploaded_date else False,
'uploaded_by': {
'id': project.uploaded_by_id.id,
'name': project.uploaded_by_id.name,
} if project.uploaded_by_id else False,
'last_update': project.last_update.isoformat() if project.last_update else False,
'last_revision_label': project.last_revision_label,
'last_revision_note': project.last_revision_note,
'last_revision_date': project.last_revision_date.isoformat() if project.last_revision_date else False,
'revised_by': {
'id': project.revised_by_id.id,
'name': project.revised_by_id.name,
} if project.revised_by_id else False,
'estimated_bom_id': project.estimated_bom_id.id if project.estimated_bom_id else False,
'permanent_bom_id': project.permanent_bom_id.id if project.permanent_bom_id else False,
'latest_mo_id': project.manufacturing_order_id.id if project.manufacturing_order_id else False,
'mo_count': len(project.manufacturing_order_ids),
'activity_count': len(project.activity_ids),
'history_count': len(project.history_ids),
'revision_count': len(project.revision_ids),
}
def _serialize_activity(self, activity):
return {
'id': activity.id,
'summary': activity.summary,
'note': activity.note,
'date_deadline': activity.date_deadline.isoformat() if activity.date_deadline else False,
'state': activity.state,
'res_id': activity.res_id,
'assigned_to': {
'id': activity.user_id.id,
'name': activity.user_id.name,
} if activity.user_id else False,
'created_at': activity.create_date.isoformat() if activity.create_date else False,
}
def _serialize_history(self, history):
return {
'id': history.id,
'activity_type': history.activity_type,
'activity_date': history.activity_date.isoformat() if history.activity_date else False,
'note': history.note,
'state_snapshot': history.state_snapshot,
'user': {
'id': history.user_id.id,
'name': history.user_id.name,
} if history.user_id else False,
}
def _serialize_bom_line(self, line):
return {
'id': line.id,
'name': line.product_id.display_name,
'product_id': line.product_id.id,
'default_code': line.product_id.default_code,
'qty': line.product_qty,
'uom': {
'id': line.product_uom_id.id,
'name': line.product_uom_id.name,
} if line.product_uom_id else False,
}
def _serialize_bom(self, bom):
return {
'id': bom.id,
'code': bom.code,
'product': {
'id': bom.product_tmpl_id.id,
'name': bom.product_tmpl_id.display_name,
'default_code': bom.product_tmpl_id.default_code,
} if bom.product_tmpl_id else False,
'quantity': bom.product_qty,
'type': bom.type,
'line_count': len(bom.line_ids),
'lines': [self._serialize_bom_line(line) for line in bom.line_ids],
}
def _serialize_purchase_order(self, purchase_order):
return {
'id': purchase_order.id,
'name': purchase_order.name,
'state': purchase_order.state,
'vendor': {
'id': purchase_order.partner_id.id,
'name': purchase_order.partner_id.name,
} if purchase_order.partner_id else False,
'origin': purchase_order.origin,
'line_count': len(purchase_order.order_line),
'lines': [
{
'id': line.id,
'product_id': line.product_id.id,
'product_name': line.product_id.display_name,
'qty': line.product_qty,
'uom_id': line.product_uom.id,
'uom_name': line.product_uom.name,
'price_unit': line.price_unit,
}
for line in purchase_order.order_line
],
}
def _serialize_manufacturing_order(self, manufacturing_order):
return {
'id': manufacturing_order.id,
'name': manufacturing_order.name,
'state': manufacturing_order.state,
'origin': manufacturing_order.origin,
'product': {
'id': manufacturing_order.product_id.id,
'name': manufacturing_order.product_id.display_name,
'default_code': manufacturing_order.product_id.default_code,
} if manufacturing_order.product_id else False,
'qty': manufacturing_order.product_qty,
'uom': {
'id': manufacturing_order.product_uom_id.id,
'name': manufacturing_order.product_uom_id.name,
} if manufacturing_order.product_uom_id else False,
'bom_id': manufacturing_order.bom_id.id if manufacturing_order.bom_id else False,
'ifc_project_id': manufacturing_order.ifc_project_id.id if manufacturing_order.ifc_project_id else False,
}
def _get_signing_secret(self):
param = request.env['ir.config_parameter'].sudo()
secret = param.get_param('database.secret') or param.get_param('database.uuid')
return secret or request.session.db or 'odoo'
def _build_temp_signature(self, file_key, document_id, user_id, expires_at):
payload = f'{file_key}|{document_id}|{user_id}|{expires_at}'
return hmac.new(
self._get_signing_secret().encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256,
).hexdigest()
def _build_signed_temp_download_url(self, file_key, document_id, user_id):
expires_at = int(time.time()) + (TEMP_TTL_MINUTES * 60)
signature = self._build_temp_signature(file_key, document_id, user_id, expires_at)
query = urlencode(
{
'doc_id': int(document_id),
'uid': int(user_id),
'exp': expires_at,
'sig': signature,
}
)
return f'/api/ifc/temp/{file_key}?{query}', expires_at
def _verify_temp_signature(self, file_key, document_id, user_id, expires_at, signature):
if not signature:
return False
try:
exp_int = int(expires_at)
except (TypeError, ValueError):
return False
if exp_int < int(time.time()):
return False
expected = self._build_temp_signature(file_key, document_id, user_id, exp_int)
return hmac.compare_digest(expected, signature)
def _get_project_for_action(self, document_id, mode='write'):
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return False, _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode=mode)
except AccessError:
return False, _jsonrpc_fail('Tidak punya akses ke dokumen.', code='ACCESS_DENIED')
return project, None
def _execute_project_action(self, document_id, method_name):
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
getattr(project, method_name)()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='ACTION_FAILED')
except Exception as exc:
return _jsonrpc_fail('Eksekusi aksi dokumen gagal.', code='ACTION_FAILED', details=str(exc))
return _jsonrpc_ok({'document': self._serialize_project(project)})
def _get_temp_root(self):
root = Path(tempfile.gettempdir()) / TEMP_SUBDIR
root.mkdir(parents=True, exist_ok=True)
return root
def _cleanup_temp_files(self):
root = self._get_temp_root()
now = datetime.datetime.now(datetime.UTC)
for file_path in root.glob('*.ifc'):
try:
age_seconds = now.timestamp() - file_path.stat().st_mtime
except OSError:
continue
if age_seconds > TEMP_TTL_MINUTES * 60:
try:
file_path.unlink()
except OSError:
pass
@http.route('/api/session/login', type='json', auth='none', methods=['POST'], csrf=False)
def api_session_login(self, db=None, login=None, password=None, **kwargs):
database = db or request.session.db
username = login
passwd = password
if not database or not username or not passwd:
return _jsonrpc_fail('db, login, dan password wajib diisi.', code='BAD_REQUEST')
try:
uid = request.session.authenticate(database, username, passwd)
except Exception as exc:
return _jsonrpc_fail('Login gagal.', code='AUTH_FAILED', details=str(exc))
if not uid:
return _jsonrpc_fail('Login gagal. Cek kredensial.', code='AUTH_FAILED')
user = request.env['res.users'].sudo().browse(uid)
return _jsonrpc_ok(
{
'uid': uid,
'name': user.name,
'login': user.login,
'db': database,
'session_id': request.session.sid,
}
)
@http.route('/api/session/logout', type='json', auth='user', methods=['POST'], csrf=False)
def api_session_logout(self, **kwargs):
request.session.logout()
return _jsonrpc_ok({'message': 'Logout berhasil.'})
@http.route('/api/session/me', type='json', auth='user', methods=['POST'], csrf=False)
def api_session_me(self, **kwargs):
user = request.env.user
return _jsonrpc_ok(
{
'uid': user.id,
'name': user.name,
'login': user.login,
'session_id': request.session.sid,
'db': request.session.db,
}
)
@http.route('/api/ifc/documents/list', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_list(self, limit=50, offset=0, search=None, **kwargs):
domain = []
if search:
domain = ['|', ('name', 'ilike', search), ('document_ref', 'ilike', search)]
projects = request.env['grt.ifc.project'].search(domain, limit=int(limit), offset=int(offset), order='id desc')
for project in projects:
self._check_record_access(project, mode='read')
return _jsonrpc_ok(
{
'count': len(projects),
'items': [self._serialize_project(project) for project in projects],
}
)
@http.route('/api/ifc/documents/create', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_create(self, name=None, file_name=None, file_data=None, revision_note=None, company_id=None, **kwargs):
if not name or not file_data:
return _jsonrpc_fail('name dan file_data (base64) wajib diisi.', code='BAD_REQUEST')
vals = {
'name': name,
'file_name': _safe_filename(file_name or '%s.ifc' % name),
'file_data': file_data,
'revision_note_input': revision_note or _('Dokumen awal diunggah via API.'),
}
if company_id:
vals['company_id'] = int(company_id)
try:
project = request.env['grt.ifc.project'].create(vals)
except Exception as exc:
return _jsonrpc_fail('Gagal membuat dokumen IFC.', code='CREATE_FAILED', details=str(exc))
self._check_record_access(project, mode='read')
return _jsonrpc_ok({'document': self._serialize_project(project)})
@http.route('/api/ifc/documents/revise', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_revise(self, document_id=None, file_data=None, file_name=None, revision_note=None, **kwargs):
if not document_id or not file_data:
return _jsonrpc_fail('document_id dan file_data (base64) wajib diisi.', code='BAD_REQUEST')
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode='write')
except AccessError:
return _jsonrpc_fail('Tidak punya akses untuk revisi dokumen.', code='ACCESS_DENIED')
vals = {
'file_data': file_data,
'revision_note_input': revision_note or _('Revisi dokumen via API.'),
}
if file_name:
vals['file_name'] = _safe_filename(file_name)
try:
project.write(vals)
except Exception as exc:
return _jsonrpc_fail('Gagal melakukan revisi dokumen.', code='REVISION_FAILED', details=str(exc))
return _jsonrpc_ok({'document': self._serialize_project(project)})
@http.route('/api/ifc/documents/parse', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_parse(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode='write')
project.action_parse_ifc()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='PARSE_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal parse IFC.', code='PARSE_FAILED', details=str(exc))
return _jsonrpc_ok({'document': self._serialize_project(project)})
@http.route('/api/ifc/documents/approval/request-validation', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_request_validation(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
return self._execute_project_action(document_id, 'action_request_validation')
@http.route('/api/ifc/documents/approval/submit', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_submit_approval(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
return self._execute_project_action(document_id, 'action_submit_for_approval')
@http.route('/api/ifc/documents/approval/approve-production', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_approve_production(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
return self._execute_project_action(document_id, 'action_approve_production')
@http.route('/api/ifc/documents/approval/approve-provisioning', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_approve_provisioning(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
return self._execute_project_action(document_id, 'action_approve_provisioning')
@http.route('/api/ifc/documents/approval/reject', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_reject(self, document_id=None, note=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
if note:
project.revision_note_input = note
project.action_reject_document()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='ACTION_FAILED')
except Exception as exc:
return _jsonrpc_fail('Reject dokumen gagal.', code='ACTION_FAILED', details=str(exc))
return _jsonrpc_ok({'document': self._serialize_project(project)})
@http.route('/api/ifc/documents/approval/back-draft', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_back_draft(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
return self._execute_project_action(document_id, 'action_back_to_draft')
@http.route('/api/ifc/documents/activities/add', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_activities_add(self, document_id=None, summary=None, note=None, user_id=None, deadline=None, **kwargs):
if not document_id or not summary:
return _jsonrpc_fail('document_id dan summary wajib diisi.', code='BAD_REQUEST')
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode='write')
except AccessError:
return _jsonrpc_fail('Tidak punya akses membuat activity.', code='ACCESS_DENIED')
target_user_id = int(user_id) if user_id else request.env.user.id
date_deadline = deadline or False
try:
project.activity_schedule(
'mail.mail_activity_data_todo',
summary=summary,
note=note or '',
user_id=target_user_id,
date_deadline=date_deadline,
)
project._log_history('validation', _('Activity baru ditambahkan via API.'))
except Exception as exc:
return _jsonrpc_fail('Gagal membuat activity.', code='ACTIVITY_FAILED', details=str(exc))
return _jsonrpc_ok({'message': 'Activity berhasil dibuat.'})
@http.route('/api/ifc/documents/activities/list', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_activities_list(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode='read')
except AccessError:
return _jsonrpc_fail('Tidak punya akses membaca activity.', code='ACCESS_DENIED')
activities = request.env['mail.activity'].search(
[
('res_model', '=', 'grt.ifc.project'),
('res_id', '=', project.id),
],
order='date_deadline asc, id desc',
)
history = request.env['grt.ifc.activity.history'].search(
[('project_id', '=', project.id)],
order='activity_date desc, id desc',
)
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'activities': [self._serialize_activity(activity) for activity in activities],
'history': [self._serialize_history(item) for item in history],
}
)
@http.route('/api/ifc/documents/bom/generate', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_bom_generate(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
project.action_create_bom_estimate()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='BOM_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal generate BoM estimasi.', code='BOM_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'estimated_bom': self._serialize_bom(project.estimated_bom_id) if project.estimated_bom_id else False,
}
)
@http.route('/api/ifc/documents/bom/get', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_bom_get(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='read')
if error:
return error
parsed_bom = [
{
'id': line.id,
'ifc_key': line.ifc_key,
'name': line.name,
'global_id': line.global_id,
'ifc_type': line.ifc_type,
'relation_type': line.relation_type,
'level': line.level,
'quantity': line.quantity,
'unit_name': line.unit_name,
'parent_id': line.parent_id.id if line.parent_id else False,
}
for line in project.bom_ids
]
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'parsed_bom': parsed_bom,
'estimated_bom': self._serialize_bom(project.estimated_bom_id) if project.estimated_bom_id else False,
'permanent_bom': self._serialize_bom(project.permanent_bom_id) if project.permanent_bom_id else False,
}
)
@http.route('/api/ifc/documents/bom/create-permanent-if-available', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_bom_create_permanent_if_available(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
permanent_bom = project.action_create_permanent_bom_if_available()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='BOM_PERMANENT_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal membuat Permanent BoM.', code='BOM_PERMANENT_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'permanent_bom': self._serialize_bom(permanent_bom),
}
)
@http.route('/api/ifc/documents/inventory/check', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_inventory_check(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='read')
if error:
return error
try:
availability = project.action_check_inventory_availability()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='INVENTORY_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal cek ketersediaan inventory.', code='INVENTORY_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'availability': availability,
}
)
@http.route('/api/ifc/documents/inventory/create-draft-purchase', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_inventory_create_draft_purchase(self, document_id=None, vendor_id=None, only_shortage=True, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
purchase_orders = project.action_create_draft_purchase_for_shortage(
vendor_id=vendor_id,
only_shortage=_to_bool(only_shortage, default=True),
)
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='PURCHASE_DRAFT_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal membuat draft purchase.', code='PURCHASE_DRAFT_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'purchase_orders': [self._serialize_purchase_order(po) for po in purchase_orders],
}
)
@http.route('/api/ifc/documents/inventory/create-product', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_inventory_create_product(self, document_id=None, ifc_bom_line_id=None, **kwargs):
if not document_id or not ifc_bom_line_id:
return _jsonrpc_fail('document_id dan ifc_bom_line_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
product, created = project.action_create_missing_product_for_ifc_bom(ifc_bom_line_id)
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='PRODUCT_CREATE_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal membuat produk dari IFC BOM.', code='PRODUCT_CREATE_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'created': created,
'product': {
'id': product.id,
'name': product.display_name,
'default_code': product.default_code,
'qty_available': product.qty_available,
'virtual_available': product.virtual_available,
},
}
)
@http.route('/api/ifc/documents/mo/create-if-available', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_create_if_available(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
manufacturing_order = project.action_create_mo_if_available()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='MO_CREATE_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal membuat Manufacturing Order.', code='MO_CREATE_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'manufacturing_order': self._serialize_manufacturing_order(manufacturing_order),
}
)
@http.route('/api/ifc/documents/mo/create-from-permanent-bom', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_create_from_permanent_bom(self, document_id=None, **kwargs):
return self.api_ifc_documents_mo_create_if_available(document_id=document_id, **kwargs)
@http.route('/api/ifc/documents/mo/check-all-availability', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_check_all_availability(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='read')
if error:
return error
try:
summary = project.action_check_all_mo_availability_summary()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='MO_AVAILABILITY_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal cek availability seluruh MO.', code='MO_AVAILABILITY_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'summary': summary,
}
)
@http.route('/api/ifc/documents/mo/confirm-all-if-available', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_confirm_all_if_available(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
confirmed_mos = project.action_confirm_all_mo_if_available()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='MO_CONFIRM_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal confirm seluruh MO.', code='MO_CONFIRM_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'confirmed_mos': [self._serialize_manufacturing_order(mo) for mo in confirmed_mos],
}
)
@http.route('/api/ifc/documents/mo/run-pipeline', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_run_pipeline(self, document_id=None, auto_confirm=False, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='write')
if error:
return error
try:
pipeline_result = project.action_run_mo_pipeline(auto_confirm=_to_bool(auto_confirm, default=False))
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='MO_PIPELINE_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal menjalankan pipeline MO.', code='MO_PIPELINE_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'estimated_availability': pipeline_result['estimated_availability'],
'permanent_bom': self._serialize_bom(pipeline_result['permanent_bom']) if pipeline_result['permanent_bom'] else False,
'manufacturing_order': self._serialize_manufacturing_order(pipeline_result['manufacturing_order']) if pipeline_result['manufacturing_order'] else False,
'mo_summary': pipeline_result['mo_summary'],
'auto_confirm': pipeline_result['auto_confirm'],
'confirmed_mos': [self._serialize_manufacturing_order(mo) for mo in pipeline_result['confirmed_mos']],
}
)
@http.route('/api/ifc/documents/mo/pipeline-dry-run', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_mo_pipeline_dry_run(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project, error = self._get_project_for_action(document_id, mode='read')
if error:
return error
try:
preview = project.action_preview_mo_pipeline()
except (AccessError, UserError) as exc:
return _jsonrpc_fail(str(exc), code='MO_PIPELINE_DRY_RUN_FAILED')
except Exception as exc:
return _jsonrpc_fail('Gagal menjalankan dry-run pipeline MO.', code='MO_PIPELINE_DRY_RUN_FAILED', details=str(exc))
return _jsonrpc_ok(
{
'document': self._serialize_project(project),
'preview': preview,
}
)
@http.route('/api/ifc/documents/temp-file', type='json', auth='user', methods=['POST'], csrf=False)
def api_ifc_documents_temp_file(self, document_id=None, **kwargs):
if not document_id:
return _jsonrpc_fail('document_id wajib diisi.', code='BAD_REQUEST')
project = request.env['grt.ifc.project'].browse(int(document_id)).exists()
if not project:
return _jsonrpc_fail('Dokumen tidak ditemukan.', code='NOT_FOUND')
try:
self._check_record_access(project, mode='read')
except AccessError:
return _jsonrpc_fail('Tidak punya akses membaca file.', code='ACCESS_DENIED')
if not project.file_data:
return _jsonrpc_fail('Dokumen tidak memiliki file IFC.', code='NO_FILE')
self._cleanup_temp_files()
token = uuid.uuid4().hex
file_name = _safe_filename(project.file_name or ('%s.ifc' % project.document_ref))
temp_root = self._get_temp_root()
temp_path = temp_root / ('%s_%s' % (token, file_name))
try:
temp_path.write_bytes(base64.b64decode(project.file_data))
except Exception as exc:
return _jsonrpc_fail('Gagal decode file IFC ke temp folder.', code='DECODE_FAILED', details=str(exc))
download_url, expires_at = self._build_signed_temp_download_url(temp_path.name, project.id, request.env.user.id)
return _jsonrpc_ok(
{
'token': token,
'file_name': file_name,
'temp_path': str(temp_path),
'download_url': download_url,
'expires_in_minutes': TEMP_TTL_MINUTES,
'expires_at_unix': expires_at,
}
)
@http.route('/api/ifc/temp/<string:file_key>', type='http', auth='user', methods=['GET'], csrf=False)
def api_ifc_temp_get(self, file_key, **kwargs):
if not file_key or '/' in file_key or '\\' in file_key:
return _json_http_response({'error': 'Invalid file key.'}, status=400)
doc_id = kwargs.get('doc_id')
signed_uid = kwargs.get('uid')
exp = kwargs.get('exp')
sig = kwargs.get('sig')
if not doc_id or not signed_uid or not exp or not sig:
return _json_http_response({'error': 'Signed URL parameters are required.'}, status=400)
try:
doc_id_int = int(doc_id)
signed_uid_int = int(signed_uid)
except (TypeError, ValueError):
return _json_http_response({'error': 'Invalid signed URL parameters.'}, status=400)
if signed_uid_int != request.env.user.id:
return _json_http_response({'error': 'Signed URL does not match current user.'}, status=403)
if not self._verify_temp_signature(file_key, doc_id_int, signed_uid_int, exp, sig):
return _json_http_response({'error': 'Invalid or expired signature.'}, status=403)
project = request.env['grt.ifc.project'].browse(doc_id_int).exists()
if not project:
return _json_http_response({'error': 'Document not found.'}, status=404)
try:
self._check_record_access(project, mode='read')
except AccessError:
return _json_http_response({'error': 'Access denied.'}, status=403)
temp_path = self._get_temp_root() / file_key
if not temp_path.exists() or not temp_path.is_file():
return _json_http_response({'error': 'Temp file not found.'}, status=404)
mimetype, _ = mimetypes.guess_type(str(temp_path))
mimetype = mimetype or 'application/octet-stream'
content = temp_path.read_bytes()
filename = file_key.split('_', 1)[1] if '_' in file_key else file_key
headers = [
('Content-Type', mimetype),
('Content-Length', str(len(content))),
('Content-Disposition', 'inline; filename="%s"' % filename),
]
return request.make_response(content, headers=headers)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="seq_grt_ifc_project_document" model="ir.sequence">
<field name="name">IFC Engineering Document</field>
<field name="code">grt.ifc.project.document</field>
<field name="prefix">IFC/%(year)s/</field>
<field name="padding">5</field>
<field name="implementation">standard</field>
</record>
</odoo>
@@ -0,0 +1,318 @@
# API Quick Reference
Dokumen ini adalah cheat sheet endpoint Product Engineering IFC untuk kebutuhan implementasi cepat.
Dokumen ini bersifat rekomendasi implementasi dan dapat direvisi mengikuti perubahan backend.
Lihat detail lengkap di [BACKEND_TECHNICAL_GUIDE.md](BACKEND_TECHNICAL_GUIDE.md) dan [FRONTEND_TECHNICAL_GUIDE.md](FRONTEND_TECHNICAL_GUIDE.md).
## State Model Singkat
- Parse state: draft -> parsed | failed
- Approval state: draft -> in_validation -> waiting_production -> waiting_provisioning -> approved (dapat rejected pada tahap validasi atau approval)
## 1) JSON-RPC Envelope
Gunakan payload berikut untuk semua endpoint type json:
```json
{
"jsonrpc": "2.0",
"method": "call",
"params": {},
"id": 1
}
```
## 2) Session Endpoints
Authentication login endpoint:
- Primary: /api/session/login
- Fallback standar Odoo: /web/session/authenticate
### Login
- Route: /api/session/login
- Auth: none
- Params: db, login, password
Contoh params:
```json
{
"db": "odoo19",
"login": "user_backend",
"password": "******"
}
```
### Logout
- Route: /api/session/logout
- Auth: user
- Params: none
### Me
- Route: /api/session/me
- Auth: user
- Params: none
## 3) Document Endpoints
### List
- Route: /api/ifc/documents/list
- Auth: user
- Params opsional: limit, offset, search
### Create
- Route: /api/ifc/documents/create
- Auth: user
- Params wajib: name, file_data
- Params opsional: file_name, revision_note, company_id
Contoh params:
```json
{
"name": "IFC Docking Station",
"file_name": "docking_station.ifc",
"file_data": "<BASE64_IFC>",
"revision_note": "Initial upload"
}
```
### Revise
- Route: /api/ifc/documents/revise
- Auth: user
- Params wajib: document_id, file_data
- Params opsional: file_name, revision_note
### Parse
- Route: /api/ifc/documents/parse
- Auth: user
- Params wajib: document_id
## 4) Approval Endpoints
### Request Validation
- Route: /api/ifc/documents/approval/request-validation
- Params wajib: document_id
### Submit Approval
- Route: /api/ifc/documents/approval/submit
- Params wajib: document_id
### Approve Production
- Route: /api/ifc/documents/approval/approve-production
- Params wajib: document_id
- Role: manager produksi
### Approve Provisioning
- Route: /api/ifc/documents/approval/approve-provisioning
- Params wajib: document_id
- Role: manager provisioning
### Reject
- Route: /api/ifc/documents/approval/reject
- Params wajib: document_id
- Params opsional: note
### Back to Draft
- Route: /api/ifc/documents/approval/back-draft
- Params wajib: document_id
## 5) Activity Endpoints
### Add Activity
- Route: /api/ifc/documents/activities/add
- Params wajib: document_id, summary
- Params opsional: note, user_id, deadline
### List Activity
- Route: /api/ifc/documents/activities/list
- Params wajib: document_id
- Response: activities aktif dan history internal
## 6) BoM Endpoints
### Generate Estimated BoM
- Route: /api/ifc/documents/bom/generate
- Params wajib: document_id
### Get BoM Data
- Route: /api/ifc/documents/bom/get
- Params wajib: document_id
- Response: parsed_bom dan estimated_bom
### Create Permanent BoM If Availability Fulfilled
- Route: /api/ifc/documents/bom/create-permanent-if-available
- Params wajib: document_id
- Rule: hanya berhasil jika availability Estimated BoM terpenuhi
- Response: data permanent_bom yang terhubung ke IFC document
## 7) Temp IFC File Endpoints
### Generate Signed Temp URL
- Route: /api/ifc/documents/temp-file
- Params wajib: document_id
- Response penting: download_url, expires_in_minutes, expires_at_unix
### Download Temp File
- Route: /api/ifc/temp/<file_key>?doc_id=...&uid=...&exp=...&sig=...
- Method: GET
- Auth: user (session)
- Catatan: URL signed, ada expiry, terikat user dan dokumen
## 8) Inventory and Procurement Endpoints
### Check Availability vs Estimated BoM
- Route: /api/ifc/documents/inventory/check
- Params wajib: document_id
- Response: ringkasan all_available, total_shortage_qty, dan detail item per komponen
### Create Draft Purchase for Shortage
- Route: /api/ifc/documents/inventory/create-draft-purchase
- Params wajib: document_id
- Params opsional: vendor_id, only_shortage (default true)
- Response: daftar draft purchase.order yang dibuat
### Create Product from IFC BOM Line
- Route: /api/ifc/documents/inventory/create-product
- Params wajib: document_id, ifc_bom_line_id
- Response: data product existing/new + flag created
## 9) Manufacturing Order Endpoints
### Create MO If Availability Fulfilled
- Route: /api/ifc/documents/mo/create-if-available
- Params wajib: document_id
- Rule: auto memastikan Permanent BoM tersedia, lalu create MO dari Permanent BoM
- Response: data manufacturing order yang baru dibuat
### Create MO from Permanent BoM
- Route: /api/ifc/documents/mo/create-from-permanent-bom
- Params wajib: document_id
### Check All MOs Availability Summary
- Route: /api/ifc/documents/mo/check-all-availability
- Params wajib: document_id
- Response: summary all MOs + summary product requirement vs on hand
### Confirm All MOs If Available
- Route: /api/ifc/documents/mo/confirm-all-if-available
- Params wajib: document_id
- Rule: hanya confirm jika summary all MOs menunjukkan all_available = true
- Response: daftar MO yang dikonfirmasi
### Run End-to-End MO Pipeline
- Route: /api/ifc/documents/mo/run-pipeline
- Params wajib: document_id
- Params opsional: auto_confirm (default false)
- Alur: check estimated availability -> create permanent bom -> create mo from permanent bom -> check all mo summary -> optional confirm all mo
- Response: ringkasan lengkap tiap tahap pipeline
### Dry Run MO Pipeline (No Data Mutation)
- Route: /api/ifc/documents/mo/pipeline-dry-run
- Params wajib: document_id
- Alur: simulasi readiness tiap tahap pipeline tanpa create/update data
- Response: preview readiness step, estimated availability, existing MO summary
### Urutan Rekomendasi Eksekusi MO
1. /api/ifc/documents/inventory/check
2. /api/ifc/documents/bom/create-permanent-if-available
3. /api/ifc/documents/mo/create-from-permanent-bom
4. /api/ifc/documents/mo/check-all-availability
5. /api/ifc/documents/mo/confirm-all-if-available (hanya bila all_available = true)
## 10) Standard Success and Error
### Success
```json
{
"success": true,
"result": {}
}
```
### Error
```json
{
"success": false,
"error": {
"code": "BAD_REQUEST",
"message": "...",
"details": "optional"
}
}
```
## 11) Error Code Quick List
- BAD_REQUEST
- AUTH_FAILED
- NOT_FOUND
- ACCESS_DENIED
- CREATE_FAILED
- REVISION_FAILED
- PARSE_FAILED
- ACTION_FAILED
- ACTIVITY_FAILED
- BOM_FAILED
- DECODE_FAILED
- INVENTORY_FAILED
- PURCHASE_DRAFT_FAILED
- PRODUCT_CREATE_FAILED
- MO_CREATE_FAILED
- BOM_PERMANENT_FAILED
- MO_AVAILABILITY_FAILED
- MO_CONFIRM_FAILED
- MO_PIPELINE_FAILED
- MO_PIPELINE_DRY_RUN_FAILED
## 12) Integration Checklist
- Login session berhasil dan cookie terkirim otomatis.
- Upload create atau revise dokumen IFC sukses.
- Parse IFC sukses dan state berubah.
- Approval flow berjalan sesuai role.
- Activity add atau list berjalan per dokumen.
- Generate atau get BoM sukses.
- Create permanent BoM dari estimated BoM sukses.
- Cek inventory availability dan trigger draft purchase sukses.
- Endpoint create product dari IFC BOM line berjalan.
- Endpoint create MO dari permanent BoM berjalan.
- Endpoint check-all-MOs availability summary dan confirm-all-MOs berjalan.
- Endpoint run-pipeline berjalan untuk eksekusi flow end-to-end.
- Endpoint pipeline-dry-run berjalan untuk preview sebelum eksekusi real.
- Temp file URL bisa dipakai viewer.
- Jika URL expired, frontend meminta URL baru.
@@ -0,0 +1,330 @@
# Backend Technical Guide
Dokumen ini ditujukan untuk developer backend Odoo 19 yang akan memelihara dan mengembangkan modul Product Engineering IFC.
Dokumen ini adalah panduan rekomendasi implementasi, bukan kontrak teknis yang kaku.
Isi dokumen dapat direview dan direvisi agar selalu selaras dengan implementasi aktual.
Lihat juga indeks dokumentasi utama di [README.md](README.md).
## 1) Scope Modul
Modul utama:
- grt_ifcopenshell: parsing IFC, ekstraksi struktur, dan BOM parse awal.
- grt_product_engineering: document management, revision tracking, approval workflow, KPI, dan JSON-RPC API untuk integrasi frontend.
Tujuan backend:
- Menyediakan endpoint berbasis JSON-RPC dengan session authentication.
- Menangani upload dan revisi dokumen IFC.
- Menangani activity check/approval dan riwayat aktivitas.
- Menyediakan endpoint decode file IFC dari binary Odoo ke temp file yang aman.
## 2) Struktur Teknis Penting
Controller API:
- grt_product_engineering/controllers/ifc_jsonrpc.py
Model inti:
- grt.ifc.project
- grt.ifc.document.revision
- grt.ifc.activity.history
- mrp.bom (inherit: ifc_project_id)
- mrp.production (inherit: ifc_project_id)
Keterangan:
- Session login/logout/me via endpoint custom.
- Semua endpoint bisnis memakai auth user.
- Kontrol akses memanfaatkan check_access_rights dan check_access_rule.
## 2.1) State Model Aktual
State parsing dokumen IFC:
- draft -> parsed
- draft -> failed
State approval dokumen:
- draft -> in_validation -> waiting_production -> waiting_provisioning -> approved
- reject dapat terjadi pada in_validation, waiting_production, waiting_provisioning
Catatan:
- Gunakan state backend ini sebagai sumber kebenaran untuk backend dan frontend.
- Jika frontend butuh label status bisnis tambahan, gunakan derived status tanpa mengubah state backend inti.
## 3) Kontrak Authentication Session
Endpoint login authentication yang direkomendasikan:
- Primary: /api/session/login
- Fallback standar Odoo: /web/session/authenticate
Endpoint login:
- URL: /api/session/login
- Type: json
- Auth: none
- Method: POST
- Params wajib: db, login, password
Contoh payload JSON-RPC untuk endpoint primary:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": "odoo19",
"login": "user_backend",
"password": "******"
},
"id": 1
}
Contoh payload JSON-RPC untuk fallback /web/session/authenticate:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": "odoo19",
"login": "user_backend",
"password": "******"
},
"id": 1
}
Response sukses:
{
"success": true,
"result": {
"uid": 7,
"name": "User A",
"login": "user_backend",
"db": "odoo19",
"session_id": "..."
}
}
Endpoint logout:
- URL: /api/session/logout
- Type: json
- Auth: user
Endpoint me:
- URL: /api/session/me
- Type: json
- Auth: user
## 4) Daftar Endpoint API Dokumen
### 4.1 Dokumen IFC
- /api/ifc/documents/list
- /api/ifc/documents/create
- /api/ifc/documents/revise
- /api/ifc/documents/parse
Ketentuan create:
- file_data harus base64 string.
- revision_note_input otomatis mengisi catatan revisi awal.
Ketentuan revise:
- membentuk revisi baru otomatis melalui method write model.
### 4.2 Approval Workflow
- /api/ifc/documents/approval/request-validation
- /api/ifc/documents/approval/submit
- /api/ifc/documents/approval/approve-production
- /api/ifc/documents/approval/approve-provisioning
- /api/ifc/documents/approval/reject
- /api/ifc/documents/approval/back-draft
Workflow state:
- draft -> in_validation -> waiting_production -> waiting_provisioning -> approved
- reject dapat terjadi pada in_validation, waiting_production, waiting_provisioning
### 4.3 Activity
- /api/ifc/documents/activities/add
- /api/ifc/documents/activities/list
Activity list mengembalikan:
- data mail.activity yang aktif
- history internal grt.ifc.activity.history
### 4.4 BoM
- /api/ifc/documents/bom/generate
- /api/ifc/documents/bom/get
- /api/ifc/documents/bom/create-permanent-if-available
BoM generate:
- memanggil action_create_bom_estimate di model dokumen
- hasil mencakup estimated_bom
### 4.5 Temp IFC File
- /api/ifc/documents/temp-file
- /api/ifc/temp/<file_key>
Flow:
1. frontend minta temp-file via JSON-RPC
2. backend decode binary ke folder temp
3. backend kirim signed download_url
4. frontend GET download_url dengan session cookie
### 4.6 Inventory and Procurement
- /api/ifc/documents/inventory/check
- /api/ifc/documents/inventory/create-draft-purchase
- /api/ifc/documents/inventory/create-product
### 4.7 Manufacturing Order
- /api/ifc/documents/mo/create-if-available
- /api/ifc/documents/mo/create-from-permanent-bom
- /api/ifc/documents/mo/check-all-availability
- /api/ifc/documents/mo/confirm-all-if-available
- /api/ifc/documents/mo/run-pipeline
- /api/ifc/documents/mo/pipeline-dry-run
Aturan endpoint MO:
- Estimated BoM harus availability terpenuhi dulu untuk membuat Permanent BoM.
- MO dibuat dari Permanent BoM yang terhubung ke dokumen IFC.
- Confirm all MO hanya boleh saat summary availability seluruh MO terpenuhi.
- Gunakan pipeline-dry-run untuk simulasi readiness tanpa perubahan data.
- Jika ada shortage, endpoint mengembalikan error yang sesuai (MO_CREATE_FAILED/MO_AVAILABILITY_FAILED/MO_CONFIRM_FAILED).
Urutan pipeline rekomendasi:
1. inventory check terhadap Estimated BoM
2. create Permanent BoM jika availability terpenuhi
3. create MO dari Permanent BoM
4. check all MO availability summary
5. confirm all MO hanya jika all_available bernilai true
## 5) Signed URL Security
Implementasi signed URL:
- Signature: HMAC SHA-256
- Payload signature: file_key | doc_id | uid | exp
- Secret source: ir.config_parameter database.secret (fallback database.uuid, fallback db name)
Validasi saat GET file:
- doc_id, uid, exp, sig wajib ada
- exp belum lewat
- uid di URL harus sama dengan user session
- signature harus valid
- user harus punya akses read ke dokumen
Keuntungan:
- mencegah penggunaan URL temp lintas user
- membatasi masa berlaku URL
## 6) Temp File Management
Lokasi decode:
- system temp dir / odoo_ifc_temp
Masa berlaku:
- TEMP_TTL_MINUTES = 60
Cleanup:
- dilakukan saat endpoint temp-file dipanggil
- file lama dihapus berdasarkan modified time
Catatan operasi:
- jika traffic tinggi, pertimbangkan scheduler cron cleanup periodik
- jika storage temp shared, pastikan izin file aman
## 7) JSON-RPC Error Contract
Format error backend:
{
"success": false,
"error": {
"code": "ACCESS_DENIED",
"message": "Tidak punya akses ke dokumen.",
"details": "optional"
}
}
Contoh code yang dipakai:
- BAD_REQUEST
- AUTH_FAILED
- NOT_FOUND
- ACCESS_DENIED
- CREATE_FAILED
- REVISION_FAILED
- PARSE_FAILED
- ACTION_FAILED
- ACTIVITY_FAILED
- BOM_FAILED
- BOM_PERMANENT_FAILED
- DECODE_FAILED
- INVENTORY_FAILED
- PURCHASE_DRAFT_FAILED
- PRODUCT_CREATE_FAILED
- MO_CREATE_FAILED
- MO_AVAILABILITY_FAILED
- MO_CONFIRM_FAILED
- MO_PIPELINE_FAILED
- MO_PIPELINE_DRY_RUN_FAILED
## 8) Checklist Backend Developer
Checklist implementasi:
- Pastikan dependency module: grt_ifcopenshell, mail, mrp, purchase, web.
- Pastikan route controller ter-load dari __init__.
- Verifikasi ACL dan record rule untuk model terkait.
- Uji semua endpoint dengan user role berbeda.
- Uji approval workflow role produksi dan provisioning.
- Uji signed URL expiry dan mismatch user.
- Uji beban upload file IFC berukuran besar.
- Uji alur permanent BoM dan MO pipeline (termasuk dry-run).
Checklist release:
- Uji modul install dan upgrade.
- Uji parse IFC sample dan IFC produksi.
- Uji generate estimated BoM.
- Uji inventory check dan draft purchase.
- Uji run-pipeline dan confirm-all-if-available.
- Uji rollback saat error parse.
## 9) Saran Pengembangan Lanjutan
Prioritas teknis:
- Tambah endpoint complete activity dan comment activity.
- Tambah audit log request API untuk tracing.
- Tambah rate limit pada endpoint login dan temp-file.
- Tambah pagination metadata standard untuk endpoint list.
- Tambah endpoint health-check API terpisah.
@@ -0,0 +1,260 @@
# Frontend Technical Guide
Dokumen ini ditujukan untuk developer frontend (Vue.js + three.js + ThatOpen) yang akan mengintegrasikan aplikasi dengan backend Odoo 19 Product Engineering.
Lihat juga indeks dokumentasi utama di [README.md](README.md).
## 1) Prinsip Integrasi
Prinsip utama:
- Gunakan JSON-RPC endpoint Odoo type json untuk operasi bisnis.
- Gunakan session cookie untuk auth setelah login.
- Jangan simpan password user di local storage.
- Untuk file IFC viewer, gunakan signed URL dari endpoint temp-file.
## 2) Alur Integrasi End-to-End
Urutan umum:
1. Login session
2. List atau create dokumen IFC
3. Parse IFC
4. Submit approval sesuai alur bisnis
5. Tambah atau tampilkan activity per dokumen
6. Generate BoM saat dibutuhkan
7. Minta signed temp URL
8. Load IFC file ke viewer menggunakan URL temp
## 3) Format Request JSON-RPC
Gunakan body seperti berikut untuk endpoint type json Odoo:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"document_id": 12
},
"id": 1
}
Catatan:
- Header yang umum: Content-Type: application/json
- Session cookie harus otomatis dikirim browser
- Jika frontend beda domain, pastikan konfigurasi CORS/proxy sesuai
## 4) Session Authentication
Endpoint login authentication yang digunakan frontend:
- Primary: /api/session/login
- Fallback standar Odoo: /web/session/authenticate
### 4.1 Login
Endpoint:
- /api/session/login
Params wajib:
- db
- login
- password
Contoh params:
{
"db": "odoo19",
"login": "user_at_company",
"password": "******"
}
### 4.2 Cek sesi aktif
Endpoint:
- /api/session/me
Gunakan untuk:
- restore session saat reload page
- validasi user sebelum load modul viewer
### 4.3 Logout
Endpoint:
- /api/session/logout
## 5) Endpoint Operasional Frontend
### 5.1 Dokumen
- /api/ifc/documents/list
- /api/ifc/documents/create
- /api/ifc/documents/revise
- /api/ifc/documents/parse
Saran UI:
- Setelah create atau revise, tampilkan status revisi terbaru.
- Setelah parse, refresh detail dokumen agar metrik parse terlihat.
### 5.2 Approval
- /api/ifc/documents/approval/request-validation
- /api/ifc/documents/approval/submit
- /api/ifc/documents/approval/approve-production
- /api/ifc/documents/approval/approve-provisioning
- /api/ifc/documents/approval/reject
- /api/ifc/documents/approval/back-draft
Saran UI:
- Tombol aksi ditampilkan berdasarkan role user dan approval_state.
- Tampilkan reason reject di form revisi berikutnya.
### 5.3 Activity
- /api/ifc/documents/activities/add
- /api/ifc/documents/activities/list
Saran UI:
- Tampilkan panel Activity (mail.activity) dan panel History terpisah.
- Sort activity berdasarkan deadline terdekat.
### 5.4 BoM
- /api/ifc/documents/bom/generate
- /api/ifc/documents/bom/get
- /api/ifc/documents/bom/create-permanent-if-available
Saran UI:
- Gunakan parsed_bom untuk tree part IFC.
- Gunakan estimated_bom untuk ringkasan manufacturing.
- Setelah estimated BoM siap dan available, trigger create-permanent-if-available.
### 5.5 Inventory and Procurement
- /api/ifc/documents/inventory/check
- /api/ifc/documents/inventory/create-draft-purchase
- /api/ifc/documents/inventory/create-product
Saran UI:
- Jalankan inventory/check sebelum tombol Create MO.
- Jika shortage > 0, tampilkan opsi create draft purchase.
- Jika komponen belum punya product, tampilkan opsi create product.
### 5.6 Manufacturing Order
- /api/ifc/documents/mo/create-if-available
- /api/ifc/documents/mo/create-from-permanent-bom
- /api/ifc/documents/mo/check-all-availability
- /api/ifc/documents/mo/confirm-all-if-available
- /api/ifc/documents/mo/run-pipeline
- /api/ifc/documents/mo/pipeline-dry-run
Saran UI:
- Aktifkan tombol Create MO hanya jika permanent BoM sudah ada dan availability terpenuhi.
- Setelah ada beberapa MO, tampilkan summary kebutuhan produk dari check-all-availability.
- Aktifkan tombol Confirm All MOs hanya jika all_available = true pada summary all MOs.
- Untuk mode otomatis, gunakan run-pipeline dengan auto_confirm=true.
- Untuk preview tanpa perubahan data, gunakan pipeline-dry-run sebelum eksekusi real.
- Jika endpoint gagal karena shortage, arahkan user ke workflow procurement.
## 6) Integrasi IFC Viewer (ThatOpen)
Flow render IFC:
1. Panggil /api/ifc/documents/temp-file dengan document_id
2. Backend mengembalikan download_url signed
3. Frontend melakukan HTTP GET ke download_url
4. Berikan URL atau blob ke pipeline loader IFC viewer
Penting:
- download_url memiliki expiry
- jika 403 atau expired, minta ulang endpoint temp-file
- jangan cache signed URL terlalu lama
## 7) Error Handling Strategy
Kontrak error:
{
"success": false,
"error": {
"code": "PARSE_FAILED",
"message": "...",
"details": "optional"
}
}
Saran mapping UI:
- BAD_REQUEST: validasi input form
- AUTH_FAILED: redirect login
- ACCESS_DENIED: tampilkan notifikasi izin
- NOT_FOUND: refresh list lalu tampilkan warning
- DECODE_FAILED: minta user upload ulang
Retry policy:
- endpoint temp-file: retry 1 kali jika timeout
- endpoint parse: jangan auto-retry, butuh konfirmasi user
- endpoint approval: jangan auto-retry untuk hindari duplikasi aksi
## 8) State Management Recommendation
Store minimal:
- auth: uid, name, login, isAuthenticated
- documents: list, selectedDocument, paging
- activities: listByDocument
- approval: availableActions
- viewer: currentIfcUrl, loadState, error
Event yang perlu memicu refresh data:
- setelah revise
- setelah parse
- setelah approval action
- setelah activity add
- setelah bom generate
## 9) Security Recommendation untuk Frontend
Checklist keamanan:
- Hindari menyimpan kredensial user secara permanen.
- Pastikan semua request menggunakan HTTPS.
- Jangan expose signed URL ke log publik.
- Bersihkan state viewer saat logout.
- Handle 401 dan 403 secara terpusat.
## 10) Checklist Integrasi Frontend
Checklist tahap awal:
- Login dan persist session berhasil.
- List dokumen dan detail dokumen berhasil.
- Upload create dan revise berhasil untuk file IFC nyata.
- Parse IFC sukses dan state berubah.
- Activity add/list tampil di UI per dokumen.
- Approval flow berjalan sesuai role.
- BoM generate/get tampil di panel BOM.
- Viewer berhasil load file dari signed temp URL.
Checklist UAT:
- Uji dokumen besar.
- Uji expiry signed URL saat viewer masih terbuka.
- Uji pergantian role manager produksi/provisioning.
- Uji sesi kadaluarsa lalu login ulang.
@@ -0,0 +1,173 @@
# Product Engineering IFC Blueprint
## Odoo 19 + IfcOpenShell + Vue.js 3D Viewer
## Document Positioning
Dokumen ini adalah dokumen rekomendasi arsitektur dan implementasi.
Dokumen ini bukan kontrak teknis kaku.
Dokumen ini boleh direview, ditingkatkan, dan direvisi agar selalu selaras dengan implementasi aktual di:
- grt_ifcopenshell
- grt_product_engineering
## Objective
Menyediakan panduan praktis pengembangan Product Engineering berbasis IFC yang:
- realistis terhadap implementasi backend saat ini
- tetap memberi arah pengembangan lanjutan
- memisahkan dengan jelas fitur yang sudah berjalan dan fitur roadmap
## Current Implementation Scope
### Modul dan Peran
- grt_ifcopenshell
- upload/parse IFC
- ekstraksi entitas IFC
- pembentukan struktur IFC BOM awal
- grt_product_engineering
- document management dan revision tracking
- approval workflow 2 layer
- activity history
- estimated BoM, permanent BoM, MO pipeline
- inventory check, draft purchase, create missing product
- JSON-RPC API untuk integrasi frontend
### Status Model Aktual
State parsing dokumen IFC:
```text
draft -> parsed | failed
```
State approval dokumen:
```text
draft -> in_validation -> waiting_production -> waiting_provisioning -> approved
\-> rejected
```
Catatan:
- Status di atas adalah status backend aktual yang harus dijadikan acuan implementasi UI/workflow.
- Status bisnis tambahan dapat ditampilkan di frontend sebagai derived status, bukan mengganti state backend.
## Aligned End-to-End Workflow (Recommended)
Urutan berikut diselaraskan dengan implementasi backend terbaru:
```text
Create Engineering Document
-> Upload IFC
-> Parse IFC (IfcOpenShell in-process di Odoo)
-> Review hasil parse dan IFC BOM
-> Create/Map Product bila diperlukan
-> Generate Estimated BoM
-> Inventory Availability Check (Estimated BoM)
-> Create Draft Purchase untuk shortage bila perlu
-> Approval Workflow (Production lalu Provisioning)
-> Create Permanent BoM (hanya jika availability memenuhi)
-> Create MO from Permanent BoM
-> Check All MO Availability Summary
-> Confirm All MO jika seluruh requirement tersedia
-> Start Production (proses MRP standar)
```
Catatan penting:
- Permanent BoM tidak dibuat sebelum Estimated BoM dinyatakan available.
- MO dibuat dari Permanent BoM.
- Tersedia dry-run pipeline untuk simulasi tanpa perubahan data.
## Recommended Architecture (Aligned)
Arsitektur saat ini:
```text
Vue.js 3D Viewer
|
v
Odoo 19 (JSON-RPC API)
|
v
grt_ifcopenshell (in-process Python library)
|
v
PostgreSQL
```
Catatan roadmap:
- IfcOpenShell Service terpisah dapat dipertimbangkan di masa depan untuk kebutuhan scaling khusus.
- Saat ini implementasi berjalan in-process di Odoo, bukan service terpisah.
## Key Principles
1. IFC tidak langsung menjadi BoM resmi.
2. Semua revisi dokumen harus tersimpan dan dapat diaudit.
3. Approval wajib sebelum pembentukan Permanent BoM dan proses MO.
4. Permanent BoM dan MO harus tertelusur ke dokumen IFC sumber.
5. Alur inventory/procurement harus mencegah konfirmasi MO saat requirement belum terpenuhi.
6. Fitur roadmap tidak boleh diasumsikan sebagai fitur produksi sebelum tersedia di modul.
## Feature Alignment Matrix
### Implemented
- Upload IFC
- Revision control dan revision history
- IFC parsing dan IFC BOM extraction
- Approval workflow 2 tahap
- Estimated BoM generation
- Inventory availability check
- Draft purchase creation untuk shortage
- Missing product creation dari IFC BOM line
- Permanent BoM creation (gated by availability)
- MO creation from permanent BoM
- MO availability summary + confirm all if available
- MO pipeline run + pipeline dry-run
- Session login/logout/me API
- Temp IFC file decode + signed URL download
### Partially Implemented / Integration-Dependent
- IFC 3D Viewer end-to-end UX (bergantung implementasi frontend)
- Product mapping UX yang lebih kaya (saat ini dominan backend/API)
### Roadmap
- ECO Integration
- Visual Revision Comparison
- Clash Detection
- Cost Simulation
- AI Mapping Recommendation
- AI Quantity Validation
- AI Procurement Recommendation
- Quality domain model khusus dan QC workflow khusus Product Engineering
## API Capability Snapshot (High Level)
- Session: login, logout, me
- Document: list, create, revise, parse
- Approval: request-validation, submit, approve-production, approve-provisioning, reject, back-draft
- Activity: add, list
- BoM: generate, get, create-permanent-if-available
- Inventory: check, create-draft-purchase, create-product
- MO: create-from-permanent-bom, check-all-availability, confirm-all-if-available, run-pipeline, pipeline-dry-run
- Temp IFC: temp-file request dan signed download
Lihat detail endpoint teknis pada dokumen backend API guide.
## Revision Policy for This Blueprint
Setiap perubahan signifikan pada backend harus diikuti pembaruan blueprint ini, minimal pada:
- status model dan alur state
- urutan workflow inti
- matriks fitur implemented vs roadmap
- catatan arsitektur aktual
Dengan pendekatan ini, dokumen tetap menjadi panduan rekomendasi yang berguna, namun tetap konsisten dengan implementasi terbaik yang sedang berjalan.
+100
View File
@@ -0,0 +1,100 @@
# Product Engineering API Docs Index
Dokumentasi ini menjadi pintu masuk utama untuk tim backend dan frontend yang mengintegrasikan Odoo 19 Product Engineering dengan IFC Viewer.
## Dokumen Utama
1. Backend technical developer guide:
[BACKEND_TECHNICAL_GUIDE.md](BACKEND_TECHNICAL_GUIDE.md)
2. Frontend technical developer guide:
[FRONTEND_TECHNICAL_GUIDE.md](FRONTEND_TECHNICAL_GUIDE.md)
3. API quick reference (cheat sheet):
[API_QUICK_REFERENCE.md](API_QUICK_REFERENCE.md)
## Peta Endpoint
### Session
- Primary login authentication: /api/session/login
- Standard Odoo login fallback: /web/session/authenticate
- /api/session/login
- /api/session/logout
- /api/session/me
### Document IFC
- /api/ifc/documents/list
- /api/ifc/documents/create
- /api/ifc/documents/revise
- /api/ifc/documents/parse
### Approval Workflow
- /api/ifc/documents/approval/request-validation
- /api/ifc/documents/approval/submit
- /api/ifc/documents/approval/approve-production
- /api/ifc/documents/approval/approve-provisioning
- /api/ifc/documents/approval/reject
- /api/ifc/documents/approval/back-draft
### Activity
- /api/ifc/documents/activities/add
- /api/ifc/documents/activities/list
### BoM
- /api/ifc/documents/bom/generate
- /api/ifc/documents/bom/get
- /api/ifc/documents/bom/create-permanent-if-available
### Inventory and Procurement
- /api/ifc/documents/inventory/check
- /api/ifc/documents/inventory/create-draft-purchase
- /api/ifc/documents/inventory/create-product
### Manufacturing Order
- /api/ifc/documents/mo/create-if-available
- /api/ifc/documents/mo/create-from-permanent-bom
- /api/ifc/documents/mo/check-all-availability
- /api/ifc/documents/mo/confirm-all-if-available
- /api/ifc/documents/mo/run-pipeline
- /api/ifc/documents/mo/pipeline-dry-run
### Temp IFC File
- /api/ifc/documents/temp-file
- /api/ifc/temp/<file_key>
## Alur Integrasi Cepat
1. Login sesi dengan endpoint session login.
2. Buat dokumen IFC dengan documents create.
3. Parse file IFC dengan documents parse.
4. Jika perlu, lakukan revisi dengan documents revise.
5. Jalankan approval bertahap sesuai role.
6. Buat activity dan monitor activity list.
7. Generate estimated BoM.
8. Create Permanent BoM jika availability Estimated BoM terpenuhi.
9. Create MO dari Permanent BoM.
10. Cek availability all MOs dan summary material.
11. Confirm all MOs jika availability summary terpenuhi.
12. Minta signed URL temp-file dan load IFC ke viewer.
## Catatan Keamanan Penting
- Semua endpoint bisnis membutuhkan session user aktif.
- Temp file IFC hanya diakses melalui signed URL ber-expiry.
- Signed URL terikat ke document id dan user id.
- Frontend harus re-request temp-file jika URL expired.
## Rekomendasi Operasional
- Gunakan HTTPS di semua environment.
- Tambahkan monitoring error per endpoint.
- Audit role akses manager produksi dan provisioning secara periodik.
- Uji dokumen IFC ukuran besar saat UAT.
@@ -0,0 +1,6 @@
from . import ifc_project_engineering
from . import ifc_document_revision
from . import ifc_activity_history
from . import mrp_bom
from . import mrp_production
from . import ifc_kpi_dashboard
@@ -0,0 +1,40 @@
from odoo import fields, models
class IfcActivityHistory(models.Model):
_name = 'grt.ifc.activity.history'
_description = 'IFC Activity History'
_order = 'activity_date desc, id desc'
project_id = fields.Many2one(
'grt.ifc.project',
string='IFC Document',
required=True,
ondelete='cascade',
index=True,
)
activity_type = fields.Selection(
[
('upload', 'Upload'),
('parse', 'Parse IFC'),
('revision', 'Revision'),
('validation', 'Validation'),
('approval_production', 'Approval Produksi'),
('approval_provisioning', 'Approval Provisioning'),
('rejected', 'Rejected'),
('bom_estimate', 'BOM Estimate'),
('manufacturing_order', 'Manufacturing Order'),
],
required=True,
index=True,
)
activity_date = fields.Datetime(string='Date', default=fields.Datetime.now, required=True, index=True)
user_id = fields.Many2one(
'res.users',
string='By',
default=lambda self: self.env.user,
required=True,
index=True,
)
note = fields.Text(string='Notes')
state_snapshot = fields.Char(string='State Snapshot')
@@ -0,0 +1,33 @@
from odoo import fields, models
class IfcDocumentRevision(models.Model):
_name = 'grt.ifc.document.revision'
_description = 'IFC Document Revision'
_order = 'revision_number desc, id desc'
project_id = fields.Many2one(
'grt.ifc.project',
string='IFC Document',
required=True,
ondelete='cascade',
index=True,
)
revision_number = fields.Integer(string='Revision', required=True, default=1, index=True)
revision_label = fields.Char(string='Revision Label', compute='_compute_revision_label', store=True)
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now, required=True)
revised_by_id = fields.Many2one(
'res.users',
string='Revised By',
default=lambda self: self.env.user,
required=True,
index=True,
)
revision_note = fields.Text(string='Revision Notes')
file_data = fields.Binary(string='IFC File Snapshot', attachment=True)
file_name = fields.Char(string='File Name')
is_current = fields.Boolean(string='Current Revision', default=False, index=True)
def _compute_revision_label(self):
for record in self:
record.revision_label = 'R%02d' % (record.revision_number or 0)
@@ -0,0 +1,172 @@
from odoo import _, fields, models, tools
class IfcKpiDashboard(models.Model):
_name = 'grt.ifc.kpi.dashboard'
_description = 'IFC KPI Dashboard'
_auto = False
_order = 'name'
name = fields.Char(string='Company', readonly=True)
company_id = fields.Many2one('res.company', string='Company', readonly=True)
total_count = fields.Integer(string='Total Documents', readonly=True)
draft_count = fields.Integer(string='Draft', readonly=True)
in_validation_count = fields.Integer(string='In Validation', readonly=True)
waiting_production_count = fields.Integer(string='Waiting Produksi', readonly=True)
waiting_provisioning_count = fields.Integer(string='Waiting Provisioning', readonly=True)
approved_count = fields.Integer(string='Approved', readonly=True)
rejected_count = fields.Integer(string='Rejected', readonly=True)
approved_month_count = fields.Integer(string='Approved This Month', readonly=True)
my_todo_count = fields.Integer(string='My To Do', compute='_compute_my_activity_cards')
my_overdue_count = fields.Integer(string='My Overdue', compute='_compute_my_activity_cards')
my_validation_count = fields.Integer(string='My Validation', compute='_compute_my_activity_cards')
my_production_count = fields.Integer(string='My Pending Produksi', compute='_compute_my_activity_cards')
my_provisioning_count = fields.Integer(string='My Pending Provisioning', compute='_compute_my_activity_cards')
def _count_my_activities(self, company_id, approval_state=None, overdue=False):
self.ensure_one()
project_domain = [('company_id', '=', company_id)]
if approval_state:
project_domain.append(('approval_state', '=', approval_state))
project_ids = self.env['grt.ifc.project'].search(project_domain).ids
if not project_ids:
return 0
domain = [
('res_model', '=', 'grt.ifc.project'),
('res_id', 'in', project_ids),
('user_id', '=', self.env.user.id),
]
if overdue:
domain.append(('date_deadline', '<', fields.Date.today()))
return self.env['mail.activity'].search_count(domain)
def _get_my_activity_project_ids(self, company_id, approval_state=None, overdue=False):
self.ensure_one()
project_domain = [('company_id', '=', company_id)]
if approval_state:
project_domain.append(('approval_state', '=', approval_state))
project_ids = self.env['grt.ifc.project'].search(project_domain).ids
if not project_ids:
return []
domain = [
('res_model', '=', 'grt.ifc.project'),
('res_id', 'in', project_ids),
('user_id', '=', self.env.user.id),
]
if overdue:
domain.append(('date_deadline', '<', fields.Date.today()))
activities = self.env['mail.activity'].search(domain)
return list(set(activities.mapped('res_id')))
def _compute_my_activity_cards(self):
for record in self:
company_id = record.company_id.id
record.my_todo_count = record._count_my_activities(company_id)
record.my_overdue_count = record._count_my_activities(company_id, overdue=True)
record.my_validation_count = record._count_my_activities(company_id, approval_state='in_validation')
record.my_production_count = record._count_my_activities(company_id, approval_state='waiting_production')
record.my_provisioning_count = record._count_my_activities(company_id, approval_state='waiting_provisioning')
def _open_documents(self, approval_state=None):
self.ensure_one()
domain = [('company_id', '=', self.company_id.id)]
if approval_state:
domain.append(('approval_state', '=', approval_state))
return {
'type': 'ir.actions.act_window',
'name': _('Engineering IFC Documents'),
'res_model': 'grt.ifc.project',
'view_mode': 'list,form',
'domain': domain,
'context': {'search_default_filter_my_documents': 0},
'target': 'current',
}
def action_open_all_documents(self):
return self._open_documents()
def action_open_waiting_production(self):
return self._open_documents('waiting_production')
def action_open_waiting_provisioning(self):
return self._open_documents('waiting_provisioning')
def action_open_validation(self):
return self._open_documents('in_validation')
def action_open_approved(self):
return self._open_documents('approved')
def action_open_rejected(self):
return self._open_documents('rejected')
def _open_my_activity_documents(self, approval_state=None, overdue=False):
self.ensure_one()
project_ids = self._get_my_activity_project_ids(
self.company_id.id,
approval_state=approval_state,
overdue=overdue,
)
return {
'type': 'ir.actions.act_window',
'name': _('My Activity Documents'),
'res_model': 'grt.ifc.project',
'view_mode': 'list,form',
'domain': [('id', 'in', project_ids)],
'context': {'search_default_filter_my_documents': 0},
'target': 'current',
}
def action_open_my_todo(self):
return self._open_my_activity_documents()
def action_open_my_overdue(self):
return self._open_my_activity_documents(overdue=True)
def action_open_my_validation(self):
return self._open_my_activity_documents(approval_state='in_validation')
def action_open_my_production(self):
return self._open_my_activity_documents(approval_state='waiting_production')
def action_open_my_provisioning(self):
return self._open_my_activity_documents(approval_state='waiting_provisioning')
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(
"""
CREATE OR REPLACE VIEW %(table)s AS (
SELECT
c.id AS id,
c.id AS company_id,
c.name AS name,
COALESCE(COUNT(p.id), 0)::integer AS total_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'draft' THEN 1 ELSE 0 END), 0)::integer AS draft_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'in_validation' THEN 1 ELSE 0 END), 0)::integer AS in_validation_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'waiting_production' THEN 1 ELSE 0 END), 0)::integer AS waiting_production_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'waiting_provisioning' THEN 1 ELSE 0 END), 0)::integer AS waiting_provisioning_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'approved' THEN 1 ELSE 0 END), 0)::integer AS approved_count,
COALESCE(SUM(CASE WHEN p.approval_state = 'rejected' THEN 1 ELSE 0 END), 0)::integer AS rejected_count,
COALESCE(
SUM(
CASE
WHEN p.approval_state = 'approved'
AND p.approved_provisioning_at >= date_trunc('month', now())
AND p.approved_provisioning_at < date_trunc('month', now()) + interval '1 month'
THEN 1
ELSE 0
END
),
0
)::integer AS approved_month_count
FROM res_company c
LEFT JOIN grt_ifc_project p ON p.company_id = c.id
GROUP BY c.id, c.name
)
""" % {'table': self._table}
)
@@ -0,0 +1,870 @@
from collections import defaultdict
from odoo import _, api, fields, models, Command
from odoo.exceptions import AccessError, UserError
class IfcProject(models.Model):
_inherit = ['grt.ifc.project', 'mail.thread', 'mail.activity.mixin']
document_ref = fields.Char(
string='Document No',
required=True,
copy=False,
default=lambda self: _('New'),
readonly=True,
tracking=True,
)
uploaded_date = fields.Datetime(
string='Uploaded On',
default=fields.Datetime.now,
readonly=True,
tracking=True,
)
uploaded_by_id = fields.Many2one(
'res.users',
string='Uploaded By',
default=lambda self: self.env.user,
readonly=True,
tracking=True,
)
last_update = fields.Datetime(string='Last Update', related='write_date', store=True, readonly=True)
revision_note_input = fields.Text(string='Revision Notes')
revision_ids = fields.One2many(
'grt.ifc.document.revision',
'project_id',
string='Document Revisions',
readonly=True,
copy=False,
)
last_revision_id = fields.Many2one(
'grt.ifc.document.revision',
string='Last Revision',
compute='_compute_last_revision',
store=True,
)
last_revision_label = fields.Char(string='Last Revision Label', related='last_revision_id.revision_label', store=True)
last_revision_note = fields.Text(string='Last Revision Notes', related='last_revision_id.revision_note', store=True)
revised_by_id = fields.Many2one('res.users', string='Revised By', related='last_revision_id.revised_by_id', store=True)
last_revision_date = fields.Datetime(string='Last Revision Date', related='last_revision_id.revision_date', store=True)
approval_state = fields.Selection(
[
('draft', 'Draft'),
('in_validation', 'In Validation'),
('waiting_production', 'Waiting Production Approval'),
('waiting_provisioning', 'Waiting Provisioning Approval'),
('approved', 'Approved'),
('rejected', 'Rejected'),
],
default='draft',
required=True,
tracking=True,
index=True,
string='Approval Status',
)
approved_production_by_id = fields.Many2one('res.users', string='Approved Production By', readonly=True)
approved_production_at = fields.Datetime(string='Production Approved At', readonly=True)
approved_provisioning_by_id = fields.Many2one('res.users', string='Approved Provisioning By', readonly=True)
approved_provisioning_at = fields.Datetime(string='Provisioning Approved At', readonly=True)
rejected_by_id = fields.Many2one('res.users', string='Rejected By', readonly=True)
rejected_at = fields.Datetime(string='Rejected At', readonly=True)
history_ids = fields.One2many(
'grt.ifc.activity.history',
'project_id',
string='Activity History',
readonly=True,
copy=False,
)
estimated_product_tmpl_id = fields.Many2one('product.template', string='Estimated Main Product')
estimated_bom_id = fields.Many2one('mrp.bom', string='Estimated BoM', copy=False, readonly=True)
permanent_bom_id = fields.Many2one('mrp.bom', string='Permanent BoM', copy=False, readonly=True)
estimated_bom_count = fields.Integer(compute='_compute_estimated_bom_count', string='Estimated BoM Count')
manufacturing_order_id = fields.Many2one('mrp.production', string='Latest Manufacturing Order', copy=False, readonly=True)
manufacturing_order_ids = fields.One2many('mrp.production', 'ifc_project_id', string='Manufacturing Orders', readonly=True)
@api.depends('revision_ids.revision_number', 'revision_ids.is_current')
def _compute_last_revision(self):
for record in self:
current = record.revision_ids.filtered(lambda rev: rev.is_current)[:1]
if current:
record.last_revision_id = current.id
continue
ordered = record.revision_ids.sorted(key=lambda rev: rev.revision_number, reverse=True)
record.last_revision_id = ordered[:1].id if ordered else False
@api.depends('estimated_bom_id')
def _compute_estimated_bom_count(self):
for record in self:
record.estimated_bom_count = 1 if record.estimated_bom_id else 0
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('document_ref') or vals['document_ref'] == _('New'):
vals['document_ref'] = self.env['ir.sequence'].next_by_code('grt.ifc.project.document') or _('New')
vals.setdefault('uploaded_date', fields.Datetime.now())
vals.setdefault('uploaded_by_id', self.env.user.id)
records = super().create(vals_list)
for record in records:
if record.file_data:
record._create_revision_entry(_('Dokumen awal diunggah.'))
record._log_history('upload', _('Upload dokumen IFC awal.'))
return records
def write(self, vals):
update_file = 'file_data' in vals and not self.env.context.get('skip_revision_auto')
revision_note = vals.get('revision_note_input') or _('File IFC diperbarui.')
result = super().write(vals)
if update_file:
for record in self:
if record.file_data:
record._create_revision_entry(revision_note)
record._log_history('revision', revision_note)
return result
def action_parse_ifc(self):
result = super().action_parse_ifc()
for record in self:
record._log_history('parse', _('IFC parsing dijalankan.'))
return result
def action_request_validation(self):
for record in self:
if record.approval_state not in ('draft', 'rejected'):
raise UserError(_('Dokumen hanya dapat divalidasi dari status Draft atau Rejected.'))
if record.state != 'parsed':
raise UserError(_('Dokumen harus diparse dahulu sebelum validasi.'))
record.write({'approval_state': 'in_validation'})
record._schedule_activity(
record.uploaded_by_id,
_('Lakukan validasi dokumen IFC %s') % (record.document_ref,),
_('Validasi teknis dokumen dan pastikan data IFC siap untuk approval.'),
)
record._log_history('validation', _('Permintaan validasi dikirim.'))
def action_submit_for_approval(self):
for record in self:
if record.approval_state not in ('in_validation', 'draft', 'rejected'):
raise UserError(_('Dokumen tidak dapat diajukan dari status saat ini.'))
if record.state != 'parsed':
raise UserError(_('Dokumen harus diparse sebelum diajukan approval.'))
record.write({'approval_state': 'waiting_production'})
production_user = record._get_first_user_of_group('grt_product_engineering.group_ifc_manager_production')
if production_user:
record._schedule_activity(
production_user,
_('Approval Produksi untuk %s') % (record.document_ref,),
_('Periksa dokumen IFC dan berikan approval tahap Produksi.'),
)
record._log_history('validation', _('Dokumen diajukan ke approval Manager Produksi.'))
def action_approve_production(self):
self._ensure_group('grt_product_engineering.group_ifc_manager_production', _('Hanya Manager Produksi yang dapat melakukan approval tahap ini.'))
for record in self:
if record.approval_state != 'waiting_production':
raise UserError(_('Dokumen tidak berada pada tahap approval Produksi.'))
record._mark_user_activities_done(_('Approved by Production Manager.'))
record.write(
{
'approval_state': 'waiting_provisioning',
'approved_production_by_id': self.env.user.id,
'approved_production_at': fields.Datetime.now(),
}
)
provisioning_user = record._get_first_user_of_group('grt_product_engineering.group_ifc_manager_provisioning')
if provisioning_user:
record._schedule_activity(
provisioning_user,
_('Approval Provisioning untuk %s') % (record.document_ref,),
_('Dokumen telah lolos approval Produksi. Lanjutkan approval Provisioning.'),
)
record._log_history('approval_production', _('Dokumen disetujui Manager Produksi.'))
def action_approve_provisioning(self):
self._ensure_group('grt_product_engineering.group_ifc_manager_provisioning', _('Hanya Manager Provisioning yang dapat melakukan approval tahap ini.'))
for record in self:
if record.approval_state != 'waiting_provisioning':
raise UserError(_('Dokumen tidak berada pada tahap approval Provisioning.'))
record._mark_user_activities_done(_('Approved by Provisioning Manager.'))
record.write(
{
'approval_state': 'approved',
'approved_provisioning_by_id': self.env.user.id,
'approved_provisioning_at': fields.Datetime.now(),
}
)
record._log_history('approval_provisioning', _('Dokumen disetujui Manager Provisioning.'))
def action_reject_document(self):
self._ensure_group_any(
[
'grt_product_engineering.group_ifc_manager_production',
'grt_product_engineering.group_ifc_manager_provisioning',
],
_('Hanya Manager Produksi atau Manager Provisioning yang dapat menolak dokumen.'),
)
for record in self:
if record.approval_state not in ('waiting_production', 'waiting_provisioning', 'in_validation'):
raise UserError(_('Dokumen tidak berada pada tahap yang dapat ditolak.'))
record._mark_user_activities_done(_('Dokumen ditolak dan perlu revisi.'))
record.write(
{
'approval_state': 'rejected',
'rejected_by_id': self.env.user.id,
'rejected_at': fields.Datetime.now(),
}
)
note = record.revision_note_input or _('Dokumen ditolak. Harap lakukan revisi.')
record._log_history('rejected', note)
if record.uploaded_by_id:
record._schedule_activity(
record.uploaded_by_id,
_('Dokumen ditolak: %s') % (record.document_ref,),
_('Lakukan revisi dokumen sesuai catatan penolakan.'),
)
def action_back_to_draft(self):
for record in self:
record.write({'approval_state': 'draft'})
record._log_history('validation', _('Dokumen dikembalikan ke Draft untuk perbaikan.'))
def action_create_bom_estimate(self):
unit_uom = self.env.ref('uom.product_uom_unit')
for record in self:
if record.state != 'parsed':
raise UserError(_('BoM estimasi hanya bisa dibuat untuk dokumen yang sudah parsed.'))
if not record.bom_ids:
raise UserError(_('Data komponen IFC belum ada. Jalankan parse IFC terlebih dahulu.'))
main_template = record.estimated_product_tmpl_id or record._get_or_create_main_product_template(unit_uom)
if not main_template:
raise UserError(_('Produk utama estimasi tidak dapat dibuat.'))
component_map = defaultdict(float)
for bom_line in record.bom_ids:
if bom_line.level == 0 and bom_line.parent_id:
continue
component_product = record._get_or_create_component_product(bom_line, unit_uom)
if component_product:
qty = bom_line.quantity if bom_line.quantity and bom_line.quantity > 0 else 1.0
component_map[component_product.id] += qty
if not component_map:
raise UserError(_('Komponen untuk BoM estimasi tidak ditemukan.'))
line_commands = [Command.clear()]
for product_id, quantity in component_map.items():
component = self.env['product.product'].browse(product_id)
line_commands.append(
Command.create(
{
'product_id': component.id,
'product_qty': quantity,
'product_uom_id': component.uom_id.id,
}
)
)
bom_vals = {
'product_tmpl_id': main_template.id,
'product_id': main_template.product_variant_id.id,
'product_qty': 1.0,
'code': '%s-%s' % (record.document_ref, record.last_revision_label or 'R00'),
'type': 'normal',
'company_id': record.company_id.id,
'ifc_project_id': record.id,
'line_ids': line_commands,
}
if record.estimated_bom_id:
record.estimated_bom_id.write(bom_vals)
estimated_bom = record.estimated_bom_id
else:
estimated_bom = self.env['mrp.bom'].create(bom_vals)
record.estimated_product_tmpl_id = main_template.id
record.estimated_bom_id = estimated_bom.id
record.permanent_bom_id = False
record._log_history('bom_estimate', _('BoM estimasi dibuat/diperbarui.'))
def action_create_permanent_bom_if_available(self):
self.ensure_one()
if not self.estimated_bom_id:
raise UserError(_('Belum ada Estimated BoM. Jalankan generate BoM terlebih dahulu.'))
availability = self._compute_inventory_availability()
if not availability.get('all_available'):
raise UserError(_('Estimated BoM belum terpenuhi availability-nya. Permanent BoM belum dapat dibuat.'))
estimated_bom = self.estimated_bom_id
product = estimated_bom.product_id or estimated_bom.product_tmpl_id.product_variant_id
if not product:
raise UserError(_('Produk utama pada Estimated BoM tidak ditemukan.'))
line_commands = [Command.clear()]
for line in estimated_bom.line_ids:
line_commands.append(
Command.create(
{
'product_id': line.product_id.id,
'product_qty': line.product_qty,
'product_uom_id': line.product_uom_id.id,
}
)
)
permanent_vals = {
'product_tmpl_id': estimated_bom.product_tmpl_id.id,
'product_id': product.id,
'product_qty': estimated_bom.product_qty or 1.0,
'code': '%s-%s-PERM' % (self.document_ref, self.last_revision_label or 'R00'),
'type': estimated_bom.type,
'company_id': self.company_id.id,
'ifc_project_id': self.id,
'is_ifc_permanent': True,
'source_estimated_bom_id': estimated_bom.id,
'line_ids': line_commands,
}
if self.permanent_bom_id:
self.permanent_bom_id.write(permanent_vals)
permanent_bom = self.permanent_bom_id
else:
permanent_bom = self.env['mrp.bom'].create(permanent_vals)
self.permanent_bom_id = permanent_bom.id
self._log_history('bom_estimate', _('Permanent BoM dibuat/diperbarui dari Estimated BoM.'))
return permanent_bom
def action_view_estimated_bom(self):
self.ensure_one()
if not self.estimated_bom_id:
raise UserError(_('Belum ada BoM estimasi.'))
return {
'type': 'ir.actions.act_window',
'name': _('Estimated BoM'),
'res_model': 'mrp.bom',
'view_mode': 'form',
'res_id': self.estimated_bom_id.id,
'target': 'current',
}
def _compute_inventory_availability(self):
self.ensure_one()
if not self.estimated_bom_id:
raise UserError(_('Belum ada Estimated BoM. Jalankan generate BoM terlebih dahulu.'))
items = []
total_required = 0.0
total_on_hand = 0.0
total_shortage = 0.0
for line in self.estimated_bom_id.line_ids:
product = line.product_id
required_qty = float(line.product_qty or 0.0)
on_hand_qty = float(product.qty_available or 0.0) if product else 0.0
forecast_qty = float(product.virtual_available or 0.0) if product else 0.0
shortage_qty = max(required_qty - on_hand_qty, 0.0)
items.append(
{
'bom_line_id': line.id,
'product_id': product.id if product else False,
'product_name': product.display_name if product else line.name,
'default_code': product.default_code if product else False,
'required_qty': required_qty,
'on_hand_qty': on_hand_qty,
'forecast_qty': forecast_qty,
'shortage_qty': shortage_qty,
'is_available': shortage_qty <= 0,
'uom_id': line.product_uom_id.id if line.product_uom_id else False,
'uom_name': line.product_uom_id.name if line.product_uom_id else False,
}
)
total_required += required_qty
total_on_hand += on_hand_qty
total_shortage += shortage_qty
return {
'document_id': self.id,
'document_ref': self.document_ref,
'estimated_bom_id': self.estimated_bom_id.id,
'total_required_qty': total_required,
'total_on_hand_qty': total_on_hand,
'total_shortage_qty': total_shortage,
'all_available': total_shortage <= 0,
'items': items,
}
def action_check_inventory_availability(self):
self.ensure_one()
availability = self._compute_inventory_availability()
self._log_history('validation', _('Cek ketersediaan inventory untuk Estimated BoM dilakukan.'))
return availability
def _select_purchase_vendor(self, product, forced_vendor=False):
self.ensure_one()
if forced_vendor:
return forced_vendor
seller = product.seller_ids[:1]
return seller.partner_id if seller else False
def action_create_draft_purchase_for_shortage(self, vendor_id=False, only_shortage=True):
self.ensure_one()
availability = self._compute_inventory_availability()
purchase_orders_by_vendor = {}
no_vendor_items = []
forced_vendor = self.env['res.partner'].browse(int(vendor_id)).exists() if vendor_id else False
PurchaseOrder = self.env['purchase.order']
for item in availability['items']:
shortage_qty = item['shortage_qty']
required_qty = item['required_qty']
if only_shortage and shortage_qty <= 0:
continue
order_qty = shortage_qty if only_shortage else required_qty
if order_qty <= 0:
continue
product = self.env['product.product'].browse(item['product_id']).exists()
if not product:
continue
vendor = self._select_purchase_vendor(product, forced_vendor=forced_vendor)
if not vendor:
no_vendor_items.append(product.display_name)
continue
po = purchase_orders_by_vendor.get(vendor.id)
if not po:
po = PurchaseOrder.create(
{
'partner_id': vendor.id,
'company_id': self.company_id.id,
'origin': '%s - %s' % (self.document_ref, _('IFC Estimated BoM Shortage')),
'notes': _('Auto-generated dari Product Engineering IFC.'),
}
)
purchase_orders_by_vendor[vendor.id] = po
seller = product.seller_ids.filtered(lambda s: s.partner_id.id == vendor.id)[:1]
price_unit = seller.price if seller else (product.standard_price or 0.0)
self.env['purchase.order.line'].create(
{
'order_id': po.id,
'product_id': product.id,
'name': product.display_name,
'product_qty': order_qty,
'product_uom': product.uom_po_id.id or product.uom_id.id,
'price_unit': price_unit,
'date_planned': fields.Datetime.now(),
}
)
if no_vendor_items:
raise UserError(
_('Vendor belum ditentukan untuk beberapa produk: %s') % ', '.join(no_vendor_items)
)
purchase_orders = list(purchase_orders_by_vendor.values())
if not purchase_orders:
raise UserError(_('Tidak ada item yang perlu dibuatkan draft purchase.'))
self._log_history('validation', _('Draft purchase dibuat dari shortage Estimated BoM.'))
return purchase_orders
def action_create_missing_product_for_ifc_bom(self, ifc_bom_line_id):
self.ensure_one()
if not ifc_bom_line_id:
raise UserError(_('ifc_bom_line_id wajib diisi.'))
bom_line = self.env['grt.ifc.bom'].browse(int(ifc_bom_line_id)).exists()
if not bom_line or bom_line.project_id.id != self.id:
raise UserError(_('Baris IFC BOM tidak valid untuk dokumen ini.'))
default_code = bom_line.global_id or bom_line.ifc_key
if not default_code:
raise UserError(_('Baris IFC tidak memiliki global_id atau ifc_key sebagai kode produk.'))
product = self.env['product.product'].search([('default_code', '=', default_code)], limit=1)
if product:
return product, False
unit_uom = self.env.ref('uom.product_uom_unit')
template = self.env['product.template'].create(
{
'name': bom_line.name,
'default_code': default_code,
'detailed_type': 'consu',
'uom_id': unit_uom.id,
'uom_po_id': unit_uom.id,
}
)
self._log_history('validation', _('Produk baru dibuat dari IFC BOM line: %s') % bom_line.name)
return template.product_variant_id, True
def _get_default_mrp_picking_type(self):
self.ensure_one()
return self.env['stock.picking.type'].search(
[
('code', '=', 'mrp_operation'),
('company_id', 'in', [self.company_id.id, False]),
],
order='company_id desc, id',
limit=1,
)
def action_create_mo_if_available(self):
self.ensure_one()
if not self.permanent_bom_id:
self.action_create_permanent_bom_if_available()
availability = self._compute_inventory_availability()
if not availability.get('all_available'):
raise UserError(_('Stok belum terpenuhi untuk semua komponen. MO belum dapat dibuat.'))
bom = self.permanent_bom_id
product = bom.product_id or bom.product_tmpl_id.product_variant_id
if not product:
raise UserError(_('Produk utama pada Estimated BoM tidak ditemukan.'))
mo_vals = {
'product_id': product.id,
'product_uom_id': product.uom_id.id,
'product_qty': bom.product_qty or 1.0,
'bom_id': bom.id,
'company_id': self.company_id.id,
'origin': '%s - %s' % (self.document_ref, _('IFC Engineering')),
'ifc_project_id': self.id,
}
picking_type = self._get_default_mrp_picking_type()
if picking_type:
mo_vals['picking_type_id'] = picking_type.id
manufacturing_order = self.env['mrp.production'].create(mo_vals)
self.manufacturing_order_id = manufacturing_order.id
self._log_history('manufacturing_order', _('Manufacturing Order dibuat: %s') % manufacturing_order.name)
return manufacturing_order
def _compute_mo_requirements(self, manufacturing_order):
requirements = []
bom = manufacturing_order.bom_id
if not bom:
return requirements
factor = (manufacturing_order.product_qty or 0.0) / (bom.product_qty or 1.0)
for line in bom.line_ids:
required_qty = (line.product_qty or 0.0) * factor
product = line.product_id
on_hand_qty = float(product.qty_available or 0.0) if product else 0.0
requirements.append(
{
'product_id': product.id if product else False,
'product_name': product.display_name if product else False,
'default_code': product.default_code if product else False,
'required_qty': required_qty,
'on_hand_qty': on_hand_qty,
'shortage_qty': max(required_qty - on_hand_qty, 0.0),
'uom_id': line.product_uom_id.id if line.product_uom_id else False,
'uom_name': line.product_uom_id.name if line.product_uom_id else False,
}
)
return requirements
def _build_all_mo_availability_summary(self, raise_if_empty=True, log=False):
self.ensure_one()
if not self.manufacturing_order_ids:
if raise_if_empty:
raise UserError(_('Belum ada Manufacturing Order untuk dokumen ini.'))
return {
'document_id': self.id,
'document_ref': self.document_ref,
'mo_count': 0,
'all_available': False,
'total_shortage_qty': 0.0,
'mo_items': [],
'product_summary': [],
}
active_mos = self.manufacturing_order_ids.filtered(lambda mo: mo.state not in ('cancel',))
if not active_mos:
if raise_if_empty:
raise UserError(_('Tidak ada Manufacturing Order aktif untuk dicek.'))
return {
'document_id': self.id,
'document_ref': self.document_ref,
'mo_count': 0,
'all_available': False,
'total_shortage_qty': 0.0,
'mo_items': [],
'product_summary': [],
}
by_product = {}
mo_summaries = []
for mo in active_mos:
mo_items = self._compute_mo_requirements(mo)
mo_shortage = 0.0
for item in mo_items:
product_id = item['product_id']
if not product_id:
continue
agg = by_product.setdefault(
product_id,
{
'product_id': product_id,
'product_name': item['product_name'],
'default_code': item['default_code'],
'required_qty': 0.0,
'on_hand_qty': item['on_hand_qty'],
'uom_id': item['uom_id'],
'uom_name': item['uom_name'],
},
)
agg['required_qty'] += item['required_qty']
mo_shortage += item['shortage_qty']
mo_summaries.append(
{
'mo_id': mo.id,
'mo_name': mo.name,
'state': mo.state,
'product_id': mo.product_id.id,
'product_name': mo.product_id.display_name,
'required_items': mo_items,
'is_available': mo_shortage <= 0,
}
)
total_shortage = 0.0
product_summary = []
for data in by_product.values():
shortage_qty = max(data['required_qty'] - data['on_hand_qty'], 0.0)
total_shortage += shortage_qty
product_summary.append(
{
**data,
'shortage_qty': shortage_qty,
'is_available': shortage_qty <= 0,
}
)
result = {
'document_id': self.id,
'document_ref': self.document_ref,
'mo_count': len(active_mos),
'all_available': total_shortage <= 0,
'total_shortage_qty': total_shortage,
'mo_items': mo_summaries,
'product_summary': product_summary,
}
if log:
self._log_history('manufacturing_order', _('Cek summary availability seluruh MO dijalankan.'))
return result
def action_check_all_mo_availability_summary(self):
self.ensure_one()
return self._build_all_mo_availability_summary(raise_if_empty=True, log=True)
def action_confirm_all_mo_if_available(self):
self.ensure_one()
summary = self.action_check_all_mo_availability_summary()
if not summary.get('all_available'):
raise UserError(_('Tidak semua kebutuhan material MO terpenuhi. Confirm MO dibatalkan.'))
confirmed = []
for mo in self.manufacturing_order_ids.filtered(lambda m: m.state in ('draft', 'confirmed')):
if mo.state == 'draft':
mo.action_confirm()
confirmed.append(mo)
if not confirmed:
raise UserError(_('Tidak ada MO pada status yang dapat dikonfirmasi.'))
self._log_history('manufacturing_order', _('Confirm seluruh MO berhasil dijalankan.'))
return confirmed
def action_run_mo_pipeline(self, auto_confirm=False):
self.ensure_one()
estimated_availability = self.action_check_inventory_availability()
if not estimated_availability.get('all_available'):
raise UserError(
_('Estimated BoM belum available sepenuhnya. Selesaikan shortage sebelum jalankan pipeline MO.')
)
permanent_bom = self.action_create_permanent_bom_if_available()
manufacturing_order = self.action_create_mo_if_available()
mo_summary = self.action_check_all_mo_availability_summary()
confirmed_mos = []
if auto_confirm:
confirmed_mos = self.action_confirm_all_mo_if_available()
self._log_history('manufacturing_order', _('Pipeline MO dari Estimated BoM berhasil dijalankan.'))
return {
'estimated_availability': estimated_availability,
'permanent_bom': permanent_bom,
'manufacturing_order': manufacturing_order,
'mo_summary': mo_summary,
'confirmed_mos': confirmed_mos,
'auto_confirm': bool(auto_confirm),
}
def action_preview_mo_pipeline(self):
self.ensure_one()
if not self.estimated_bom_id:
raise UserError(_('Belum ada Estimated BoM. Jalankan generate BoM terlebih dahulu.'))
estimated_availability = self._compute_inventory_availability()
can_create_permanent_bom = bool(estimated_availability.get('all_available'))
permanent_bom_exists = bool(self.permanent_bom_id)
can_create_mo = can_create_permanent_bom or permanent_bom_exists
existing_mo_summary = self._build_all_mo_availability_summary(raise_if_empty=False, log=False)
can_confirm_all_existing_mos = bool(existing_mo_summary.get('mo_count')) and bool(existing_mo_summary.get('all_available'))
return {
'document_id': self.id,
'document_ref': self.document_ref,
'estimated_bom_id': self.estimated_bom_id.id,
'permanent_bom_id': self.permanent_bom_id.id if self.permanent_bom_id else False,
'estimated_availability': estimated_availability,
'can_create_permanent_bom': can_create_permanent_bom,
'can_create_mo': can_create_mo,
'existing_mo_summary': existing_mo_summary,
'can_confirm_all_existing_mos': can_confirm_all_existing_mos,
'pipeline_steps': [
{
'step': 'check_estimated_availability',
'ready': True,
},
{
'step': 'create_permanent_bom',
'ready': can_create_permanent_bom,
},
{
'step': 'create_mo_from_permanent_bom',
'ready': can_create_mo,
},
{
'step': 'check_all_mo_availability',
'ready': True,
},
{
'step': 'confirm_all_mo_if_available',
'ready': can_confirm_all_existing_mos,
},
],
}
def _get_or_create_main_product_template(self, unit_uom):
self.ensure_one()
default_code = self.document_ref
template = self.env['product.template'].search([('default_code', '=', default_code)], limit=1)
if template:
return template
return self.env['product.template'].create(
{
'name': self.name,
'default_code': default_code,
'detailed_type': 'product',
'uom_id': unit_uom.id,
'uom_po_id': unit_uom.id,
}
)
def _get_or_create_component_product(self, bom_line, unit_uom):
default_code = bom_line.global_id or bom_line.ifc_key
if not default_code:
return False
product = self.env['product.product'].search([('default_code', '=', default_code)], limit=1)
if product:
return product
template = self.env['product.template'].create(
{
'name': bom_line.name,
'default_code': default_code,
'detailed_type': 'consu',
'uom_id': unit_uom.id,
'uom_po_id': unit_uom.id,
}
)
return template.product_variant_id
def _create_revision_entry(self, note):
self.ensure_one()
self.revision_ids.filtered(lambda rev: rev.is_current).write({'is_current': False})
next_revision = (max(self.revision_ids.mapped('revision_number')) + 1) if self.revision_ids else 1
self.env['grt.ifc.document.revision'].create(
{
'project_id': self.id,
'revision_number': next_revision,
'revision_date': fields.Datetime.now(),
'revised_by_id': self.env.user.id,
'revision_note': note,
'file_data': self.file_data,
'file_name': self.file_name,
'is_current': True,
}
)
def _log_history(self, activity_type, note):
self.ensure_one()
self.env['grt.ifc.activity.history'].create(
{
'project_id': self.id,
'activity_type': activity_type,
'user_id': self.env.user.id,
'note': note,
'state_snapshot': self.approval_state,
}
)
def _schedule_activity(self, user, summary, note):
self.ensure_one()
if not user:
return
self.activity_schedule(
'mail.mail_activity_data_todo',
user_id=user.id,
summary=summary,
note=note,
)
def _mark_user_activities_done(self, feedback):
self.ensure_one()
open_activities = self.activity_ids.filtered(lambda act: act.user_id == self.env.user)
for activity in open_activities:
activity.action_feedback(feedback=feedback)
def _get_first_user_of_group(self, group_xmlid):
group = self.env.ref(group_xmlid, raise_if_not_found=False)
if not group or not group.users:
return False
return group.users[0]
def _ensure_group(self, group_xmlid, error_message):
if not self.env.user.has_group(group_xmlid):
raise AccessError(error_message)
def _ensure_group_any(self, group_xmlids, error_message):
if not any(self.env.user.has_group(group_xmlid) for group_xmlid in group_xmlids):
raise AccessError(error_message)
@@ -0,0 +1,9 @@
from odoo import fields, models
class MrpBom(models.Model):
_inherit = 'mrp.bom'
ifc_project_id = fields.Many2one('grt.ifc.project', string='IFC Engineering Document', index=True)
is_ifc_permanent = fields.Boolean(string='IFC Permanent BoM', default=False, index=True)
source_estimated_bom_id = fields.Many2one('mrp.bom', string='Source Estimated BoM', copy=False)
@@ -0,0 +1,7 @@
from odoo import fields, models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
ifc_project_id = fields.Many2one('grt.ifc.project', string='IFC Engineering Document', index=True)
@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_grt_ifc_document_revision_user,access.grt.ifc.document.revision.user,model_grt_ifc_document_revision,base.group_user,1,0,0,0
access_grt_ifc_document_revision_manager_production,access.grt.ifc.document.revision.manager.production,model_grt_ifc_document_revision,grt_product_engineering.group_ifc_manager_production,1,1,1,0
access_grt_ifc_document_revision_manager_provisioning,access.grt.ifc.document.revision.manager.provisioning,model_grt_ifc_document_revision,grt_product_engineering.group_ifc_manager_provisioning,1,1,1,0
access_grt_ifc_activity_history_user,access.grt.ifc.activity.history.user,model_grt_ifc_activity_history,base.group_user,1,0,0,0
access_grt_ifc_kpi_dashboard_user,access.grt.ifc.kpi.dashboard.user,model_grt_ifc_kpi_dashboard,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_grt_ifc_document_revision_user access.grt.ifc.document.revision.user model_grt_ifc_document_revision base.group_user 1 0 0 0
3 access_grt_ifc_document_revision_manager_production access.grt.ifc.document.revision.manager.production model_grt_ifc_document_revision grt_product_engineering.group_ifc_manager_production 1 1 1 0
4 access_grt_ifc_document_revision_manager_provisioning access.grt.ifc.document.revision.manager.provisioning model_grt_ifc_document_revision grt_product_engineering.group_ifc_manager_provisioning 1 1 1 0
5 access_grt_ifc_activity_history_user access.grt.ifc.activity.history.user model_grt_ifc_activity_history base.group_user 1 0 0 0
6 access_grt_ifc_kpi_dashboard_user access.grt.ifc.kpi.dashboard.user model_grt_ifc_kpi_dashboard base.group_user 1 0 0 0
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="group_ifc_manager_production" model="res.groups">
<field name="name">IFC Manager Produksi</field>
<field name="category_id" ref="base.module_category_manufacturing"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_ifc_manager_provisioning" model="res.groups">
<field name="name">IFC Manager Provisioning</field>
<field name="category_id" ref="base.module_category_manufacturing"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
</odoo>
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_grt_ifc_kpi_dashboard_list" model="ir.ui.view">
<field name="name">grt.ifc.kpi.dashboard.list</field>
<field name="model">grt.ifc.kpi.dashboard</field>
<field name="arch" type="xml">
<list string="IFC KPI Dashboard" create="0" edit="0" delete="0">
<field name="name"/>
<field name="total_count"/>
<field name="in_validation_count"/>
<field name="waiting_production_count"/>
<field name="waiting_provisioning_count"/>
<field name="approved_count"/>
<field name="approved_month_count"/>
<field name="rejected_count"/>
</list>
</field>
</record>
<record id="view_grt_ifc_kpi_dashboard_form" model="ir.ui.view">
<field name="name">grt.ifc.kpi.dashboard.form</field>
<field name="model">grt.ifc.kpi.dashboard</field>
<field name="arch" type="xml">
<form string="IFC KPI Dashboard" create="0" edit="0" delete="0">
<sheet>
<group>
<field name="name" readonly="1"/>
<field name="company_id" readonly="1"/>
</group>
<group string="My KPI Cards">
<button name="action_open_my_todo" type="object" class="oe_stat_button" icon="fa-tasks">
<field name="my_todo_count" widget="statinfo" string="My To Do"/>
</button>
<button name="action_open_my_overdue" type="object" class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="my_overdue_count" widget="statinfo" string="My Overdue"/>
</button>
<button name="action_open_my_validation" type="object" class="oe_stat_button" icon="fa-check-square">
<field name="my_validation_count" widget="statinfo" string="My Validation"/>
</button>
<button name="action_open_my_production" type="object" class="oe_stat_button" icon="fa-industry">
<field name="my_production_count" widget="statinfo" string="My Produksi"/>
</button>
<button name="action_open_my_provisioning" type="object" class="oe_stat_button" icon="fa-cubes">
<field name="my_provisioning_count" widget="statinfo" string="My Provisioning"/>
</button>
</group>
<group string="Approval Workload">
<button name="action_open_waiting_production" type="object" class="oe_stat_button" icon="fa-clock-o">
<field name="waiting_production_count" widget="statinfo" string="Pending Produksi"/>
</button>
<button name="action_open_waiting_provisioning" type="object" class="oe_stat_button" icon="fa-hourglass-half">
<field name="waiting_provisioning_count" widget="statinfo" string="Pending Provisioning"/>
</button>
<button name="action_open_validation" type="object" class="oe_stat_button" icon="fa-check-square-o">
<field name="in_validation_count" widget="statinfo" string="In Validation"/>
</button>
</group>
<group string="Outcome">
<button name="action_open_approved" type="object" class="oe_stat_button" icon="fa-thumbs-up">
<field name="approved_count" widget="statinfo" string="Approved"/>
</button>
<button name="action_open_rejected" type="object" class="oe_stat_button" icon="fa-thumbs-down">
<field name="rejected_count" widget="statinfo" string="Rejected"/>
</button>
<button name="action_open_all_documents" type="object" class="oe_stat_button" icon="fa-folder-open">
<field name="total_count" widget="statinfo" string="Total Documents"/>
</button>
</group>
<group>
<field name="approved_month_count" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_grt_ifc_kpi_dashboard_graph" model="ir.ui.view">
<field name="name">grt.ifc.kpi.dashboard.graph</field>
<field name="model">grt.ifc.kpi.dashboard</field>
<field name="arch" type="xml">
<graph string="IFC KPI Dashboard" type="bar">
<field name="name" type="row"/>
<field name="in_validation_count" type="measure"/>
<field name="waiting_production_count" type="measure"/>
<field name="waiting_provisioning_count" type="measure"/>
<field name="approved_count" type="measure"/>
<field name="rejected_count" type="measure"/>
</graph>
</field>
</record>
<record id="view_grt_ifc_kpi_dashboard_pivot" model="ir.ui.view">
<field name="name">grt.ifc.kpi.dashboard.pivot</field>
<field name="model">grt.ifc.kpi.dashboard</field>
<field name="arch" type="xml">
<pivot string="IFC KPI Dashboard">
<field name="name" type="row"/>
<field name="in_validation_count" type="measure"/>
<field name="waiting_production_count" type="measure"/>
<field name="waiting_provisioning_count" type="measure"/>
<field name="approved_count" type="measure"/>
<field name="rejected_count" type="measure"/>
</pivot>
</field>
</record>
<record id="action_grt_ifc_kpi_dashboard" model="ir.actions.act_window">
<field name="name">Engineering KPI Dashboard</field>
<field name="res_model">grt.ifc.kpi.dashboard</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Pantau KPI approval dokumen IFC per perusahaan.</p>
</field>
</record>
</odoo>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_grt_ifc_engineering_document" model="ir.actions.act_window">
<field name="name">Engineering IFC Documents</field>
<field name="res_model">grt.ifc.project</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_grt_ifc_project_search_engineering"/>
<field name="context">{'search_default_filter_my_documents': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Buat dokumen IFC engineering pertama Anda</p>
</field>
</record>
<record id="view_grt_ifc_project_search_engineering" model="ir.ui.view">
<field name="name">grt.ifc.project.search.engineering</field>
<field name="model">grt.ifc.project</field>
<field name="arch" type="xml">
<search string="Engineering IFC Documents">
<field name="name"/>
<field name="document_ref"/>
<field name="uploaded_by_id"/>
<filter name="filter_my_documents" string="My Documents" domain="[('uploaded_by_id', '=', uid)]"/>
<filter name="filter_waiting_production" string="Waiting Produksi" domain="[('approval_state', '=', 'waiting_production')]"/>
<filter name="filter_waiting_provisioning" string="Waiting Provisioning" domain="[('approval_state', '=', 'waiting_provisioning')]"/>
<filter name="filter_approved" string="Approved" domain="[('approval_state', '=', 'approved')]"/>
<group expand="0" string="Group By">
<filter name="group_uploaded_by" string="Uploaded By" context="{'group_by': 'uploaded_by_id'}"/>
<filter name="group_approval_state" string="Approval State" context="{'group_by': 'approval_state'}"/>
</group>
</search>
</field>
</record>
<menuitem
id="menu_grt_ifc_document_management"
name="Document Management"
parent="grt_ifcopenshell.menu_grt_ifc_root"
action="action_grt_ifc_engineering_document"
sequence="20"
groups="base.group_user"
/>
<menuitem
id="menu_grt_ifc_kpi_dashboard"
name="KPI Dashboard"
parent="menu_grt_ifc_document_management"
action="action_grt_ifc_kpi_dashboard"
sequence="30"
groups="base.group_user"
/>
</odoo>
@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_grt_ifc_project_tree_inherit_engineering" model="ir.ui.view">
<field name="name">grt.ifc.project.tree.inherit.engineering</field>
<field name="model">grt.ifc.project</field>
<field name="inherit_id" ref="grt_ifcopenshell.view_grt_ifc_project_tree"/>
<field name="arch" type="xml">
<xpath expr="//list/field[@name='name']" position="before">
<field name="document_ref"/>
</xpath>
<xpath expr="//list/field[@name='state']" position="before">
<field name="approval_state"/>
<field name="last_revision_label" optional="show"/>
</xpath>
</field>
</record>
<record id="view_grt_ifc_project_form_inherit_engineering" model="ir.ui.view">
<field name="name">grt.ifc.project.form.inherit.engineering</field>
<field name="model">grt.ifc.project</field>
<field name="inherit_id" ref="grt_ifcopenshell.view_grt_ifc_project_form"/>
<field name="arch" type="xml">
<xpath expr="//form/header" position="inside">
<button name="action_request_validation"
string="Request Validation"
type="object"
class="btn-secondary"
invisible="state != 'parsed' or approval_state not in ('draft', 'rejected')"/>
<button name="action_submit_for_approval"
string="Submit Approval"
type="object"
class="btn-primary"
invisible="state != 'parsed' or approval_state not in ('draft', 'in_validation', 'rejected')"/>
<button name="action_approve_production"
string="Approve Produksi"
type="object"
class="btn-primary"
groups="grt_product_engineering.group_ifc_manager_production"
invisible="approval_state != 'waiting_production'"/>
<button name="action_approve_provisioning"
string="Approve Provisioning"
type="object"
class="btn-primary"
groups="grt_product_engineering.group_ifc_manager_provisioning"
invisible="approval_state != 'waiting_provisioning'"/>
<button name="action_reject_document"
string="Reject"
type="object"
class="btn-secondary"
groups="grt_product_engineering.group_ifc_manager_production,grt_product_engineering.group_ifc_manager_provisioning"
invisible="approval_state not in ('in_validation', 'waiting_production', 'waiting_provisioning')"/>
<button name="action_back_to_draft"
string="Back to Draft"
type="object"
class="btn-secondary"
invisible="approval_state == 'draft'"/>
<field name="approval_state" widget="statusbar" statusbar_visible="draft,in_validation,waiting_production,waiting_provisioning,approved,rejected"/>
</xpath>
<xpath expr="//form/sheet" position="inside">
<div class="oe_button_box" name="button_box">
<button name="action_view_estimated_bom"
type="object"
class="oe_stat_button"
icon="fa-sitemap"
invisible="estimated_bom_count == 0">
<field name="estimated_bom_count" widget="statinfo" string="Estimated BoM"/>
</button>
</div>
</xpath>
<xpath expr="//form/sheet/group[1]" position="before">
<group string="Document Management">
<group>
<field name="document_ref"/>
<field name="uploaded_date"/>
<field name="uploaded_by_id"/>
<field name="last_update"/>
</group>
<group>
<field name="last_revision_label"/>
<field name="last_revision_date"/>
<field name="revised_by_id"/>
<field name="last_revision_note"/>
</group>
</group>
</xpath>
<xpath expr="//group[field[@name='sample_file_key']]" position="inside">
<field name="revision_note_input" placeholder="Catatan perubahan revisi..."/>
</xpath>
<xpath expr="//form/sheet/notebook" position="inside">
<page string="Revisions">
<field name="revision_ids" readonly="1">
<list string="Revision History" create="0" delete="0">
<field name="revision_label"/>
<field name="revision_date"/>
<field name="revised_by_id"/>
<field name="revision_note"/>
<field name="is_current"/>
<field name="file_name" optional="show"/>
</list>
</field>
</page>
<page string="Approval &amp; Activity History">
<field name="history_ids" readonly="1">
<list string="Activity History" create="0" delete="0">
<field name="activity_date"/>
<field name="activity_type"/>
<field name="user_id"/>
<field name="state_snapshot"/>
<field name="note"/>
</list>
</field>
</page>
<page string="BoM Estimation">
<group>
<field name="estimated_product_tmpl_id"/>
<field name="estimated_bom_id" readonly="1"/>
</group>
<group>
<button name="action_create_bom_estimate" type="object" class="btn-primary" string="Generate / Update Estimated BoM"/>
</group>
</page>
</xpath>
<xpath expr="//form" position="inside">
<chatter/>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mrp_bom_form_inherit_ifc_project" model="ir.ui.view">
<field name="name">mrp.bom.form.inherit.ifc.project</field>
<field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='code']" position="after">
<field name="ifc_project_id" readonly="1"/>
</xpath>
</field>
</record>
</odoo>