Perbaikan komplit Dashboard

This commit is contained in:
2026-03-13 04:36:51 +07:00
parent 28d116848c
commit 0ff0dd7753
92 changed files with 2439 additions and 8492 deletions
+101
View File
@@ -0,0 +1,101 @@
# DEPLOYMENT & VERIFICATION GUIDE
# SILO OEE Equipment Fix - grt_scada Module
## Issue Yang Diperbaiki
- ✅ SILO equipment sekarang akan menampilkan OEE records dari `scada.equipment.oee.line` (detail level)
- ✅ Main PLC terus menampilkan OEE records dari header level (tidak ada perubahan)
## Modified File
- `grt_scada/controllers/main.py`
- Function: `get_oee_equipment_avg()` [around line 980-990]
- Change: Added fallback untuk count OEE line records jika header OEE count = 0
## Deployment Checklist
### ✅ Pre-Deployment
- [ ] File `main.py` sudah di-edit lokal
- [ ] Syntax validation sudah OK (python -m py_compile)
- [ ] Backup original file jika perlu
### 🚀 DEPLOYMENT STEPS
#### Step 1: Upload File ke Server
```bash
# Option A: Via SCP (recommended)
scp /path/to/grt_scada/controllers/main.py user@kanjabung.web.id:/opt/odoo/addons/grt_scada/controllers/
# Option B: Via SFTP (WinSCP, FileZilla)
# Connect to server, navigate to /opt/odoo/addons/grt_scada/controllers/
# Upload main.py file
# Option C: Via Git (if repo is synced)
git push origin main # on local
# Then pull on server
```
#### Step 2: Restart Odoo Service
```bash
# Login ke server
ssh user@kanjabung.web.id
# Restart Odoo (gunakan salah satu)
sudo systemctl restart odoo14
# atau
sudo /etc/init.d/odoo14 restart
# atau
sudo supervisorctl restart odoo
# Tunggu 30 detik, kemudian cek status
sudo systemctl status odoo14
# Pastikan: Active (running)
```
#### Step 3: Upgrade Module di Odoo UI
1. Login ke Odoo: https://kanjabung.web.id
2. Go to **Apps** menu (hamburger icon top-left)
3. Search bar: type "grt_scada"
4. Click on **SCADA** module
5. Click **Upgrade** button (top-right)
6. Tunggu sampai status berubah jadi "Installed"
#### Step 4: Verify Deployment
```powershell
# Run test script setelah restart selesai
cd c:\addon14
.\test_silo_oee_after_deployment.ps1
```
Expected output:
```
✓ Authentication successful
✓ API call successful
Equipment with OEE Data:
• SILO A (silo101)
- OEE Records: XXX
- Avg Yield: YY.YY%
• Main PLC - Injection Machine 01 (plc01)
- OEE Records: 64
- Avg Yield: 100.0%
SILO equipment with data: 1+
LQ equipment with data: 1+
PLC equipment with data: 1+
✓ SUCCESS: SILO equipment sekarang muncul dengan OEE records!
```
## Rollback (Jika Ada Masalah)
```bash
# Restore original file
# Restart Odoo
sudo systemctl restart odoo14
# Upgrade module di UI
```
## Support Info
- Server: https://kanjabung.web.id
- Database: kanjabung_MRP
- Module: grt_scada
- Date Range: 2026-02-24 to 2026-03-13
@@ -19,6 +19,7 @@ This enables different staging flow per business category through team pipelines
"views/crm_activity_history_views.xml",
"views/crm_business_category_views.xml",
"views/crm_lead_views.xml",
"views/crm_menu_views.xml",
"views/crm_stage_views.xml",
"views/crm_team_views.xml",
"views/crm_team_business_category_views.xml",
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="crm.menu_crm_lead_stage_act" model="ir.ui.menu">
<field
name="groups_id"
eval="[(6, 0, [ref('sales_team.group_sale_manager'), ref('base.group_system')])]"
/>
</record>
</odoo>
+75
View File
@@ -0,0 +1,75 @@
# API Report Dashboard SCADA Vue.js (One-Page)
Dokumen ringkas untuk handoff tim frontend Vue.js.
Fokus: endpoint report, params minimum, output wajib, dan pola integrasi.
## Base URL
`/api/scada`
## Endpoint Report Inti
| Endpoint | Method | Params Minimum (`params`) | Output Wajib untuk Dashboard |
|---|---|---|---|
| `/today-reports` | `POST` JSON-RPC | `{ "date": "YYYY-MM-DD", "limit": 200 }` | `batch_status`, `total_production_today`, `oee_quality_today`, `batch_to_batch_deviation_chart.data[]` |
| `/periodic-report` | `POST` JSON-RPC | `{ "period": "this_month", "limit": 1000 }` | `metrics_total_production`, `metrics_total_mo`, `metrics_avg_oee_quality`, `chart_daily_target_vs_actual.data[]`, `chart_raw_material_consumption.data[]`, `table_silo_consumption_stock.data[]`, `table_daily_finished_goods.data[]` |
| `/oee-equipment-avg` | `POST` JSON-RPC | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `data[].equipment`, `data[].avg_summary`, `data[].avg_consumption_detail`, `data[].oee_records_count` |
| `/oee-detail` | `POST` JSON-RPC | `{ "mo_id": "WH/MO/00001", "limit": 50, "offset": 0 }` | `data[].mo_id`, `data[].product_name`, `data[].yield_percent`, `data[].consumption_ratio`, `data[].lines[]` |
| `/kpi-product-report` | `POST` JSON-RPC | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `summary.total_products`, `summary.total_oee_records`, `data[].product_name`, `data[].avg_kpi` |
| `/equipment-failure-report` | `POST` JSON-RPC | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `summary.total_failures`, `summary.equipment_count`, `summary.by_equipment[]`, `data[]` |
## Endpoint Pendukung Filter
| Endpoint | Method | Fungsi |
|---|---|---|
| `/products` | `GET` / `POST` JSON-RPC | Dropdown produk |
| `/products-by-category` | `POST` JSON-RPC | Dropdown bahan baku per kategori |
| `/boms` | `GET` / `POST` JSON-RPC | Komposisi BoM |
| `/mo-list` | `GET` | Daftar MO per equipment/status |
| `/mo-list-confirmed` | `POST` JSON-RPC | Queue MO confirmed |
| `/mo-list-detailed` | `POST` JSON-RPC | Detail MO + komponen + consumption |
## Kontrak Request (POST Report)
```json
{
"jsonrpc": "2.0",
"method": "call",
"params": {}
}
```
## Template Fetch Frontend
```javascript
async function callScadaReport(endpoint, params = {}) {
const response = await fetch(`/api/scada/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params,
}),
});
const payload = await response.json();
if (!response.ok || payload?.status === 'error' || payload?.error) {
throw new Error(payload?.message || payload?.error?.message || 'Request failed');
}
return payload;
}
```
## Checklist Integrasi Minimum
1. Selalu kirim `credentials: 'include'`.
2. Selalu cek `status` sebelum render data.
3. Prioritaskan `today-reports` dan `periodic-report` untuk chart utama.
4. Endpoint lain umumnya data mentah dan perlu mapping chart/table di frontend.
## Referensi Detail Lengkap
Lihat dokumen utama: `API_SPEC.md` (section API report & endpoint detail lengkap).
+210
View File
@@ -83,6 +83,216 @@ curl -X POST http://localhost:8069/api/scada/material-consumption \
## Endpoints Reference
## API Report Khusus Dashboard SCADA Vue.js
Bagian ini merangkum endpoint yang difokuskan untuk kebutuhan **reporting dashboard** di frontend Vue.js.
Dokumen ringkas terpisah (1 halaman): [API_REPORT_DASHBOARD_VUEJS.md](API_REPORT_DASHBOARD_VUEJS.md)
### Endpoint Inti Report Dashboard
| No | Endpoint | Method | Kegunaan Dashboard | Output Utama |
|---|---|---|---|---|
| 1 | `/api/scada/today-reports` | `POST` (JSON-RPC) | Ringkasan harian (KPI, batch, quality) | `batch_status`, `total_production_today`, `oee_quality_today`, `batch_to_batch_deviation_chart` |
| 2 | `/api/scada/periodic-report` | `POST` (JSON-RPC) | Ringkasan periodik mingguan/bulanan/tahunan | `metrics_*`, `chart_daily_target_vs_actual`, `chart_raw_material_consumption`, `table_*` |
| 3 | `/api/scada/oee-equipment-avg` | `POST` (JSON-RPC) | Analitik rata-rata OEE per equipment | `data[].avg_summary`, `data[].avg_consumption_detail` |
| 4 | `/api/scada/oee-detail` | `POST` (JSON-RPC) | Drill-down OEE per batch/MO/equipment | `data[]`, `data[].lines` |
| 5 | `/api/scada/kpi-product-report` | `POST` (JSON-RPC) | KPI performa per produk | `data[].avg_kpi`, `summary` |
| 6 | `/api/scada/equipment-failure-report` | `POST` (JSON-RPC) | Report failure & downtime equipment | `data[]`, `summary.total_failures`, `summary.by_equipment` |
### Endpoint Pendukung Filter Dashboard Report
| Endpoint | Method | Fungsi Filter |
|---|---|---|
| `/api/scada/products` | `GET` / `POST` (JSON-RPC) | Sumber dropdown produk |
| `/api/scada/products-by-category` | `POST` (JSON-RPC) | Sumber dropdown bahan baku per kategori |
| `/api/scada/boms` | `GET` / `POST` (JSON-RPC) | Detail komposisi BoM untuk analitik konsumsi |
| `/api/scada/mo-list` | `GET` | Daftar MO per equipment/status |
| `/api/scada/mo-list-confirmed` | `POST` (JSON-RPC) | Queue MO confirmed untuk picker/frontend |
| `/api/scada/mo-list-detailed` | `POST` (JSON-RPC) | Detail MO + komponen + consumption |
### Contract Request untuk Vue.js
Semua endpoint report dengan method `POST` pada tabel di atas menggunakan body JSON-RPC:
```json
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"period": "this_month",
"limit": 100,
"offset": 0
}
}
```
Catatan frontend:
1. Gunakan `credentials: 'include'` agar session cookie Odoo ikut terkirim.
2. Cek `status` response sebelum membaca `data`/`summary`.
3. Untuk report chart, prioritaskan endpoint `today-reports` dan `periodic-report` karena sudah menyediakan blok chart siap map ke ApexCharts.
### Cheatsheet Copy-Paste Frontend (Vue.js)
Gunakan section ini sebagai referensi cepat implementasi dashboard/report di frontend.
#### 1) `POST /api/scada/today-reports`
**Params minimum (JSON-RPC):**
```json
{
"date": "2026-02-17",
"limit": 200
}
```
**Field utama untuk UI:**
- KPI: `batch_status.scheduled_total`, `batch_status.completed`, `batch_status.unfinished`
- KPI: `total_production_today.qty_finished`, `total_production_today.completed_batch_count`
- KPI: `oee_quality_today.avg_yield_percent`, `oee_quality_today.avg_consumption_ratio`, `oee_quality_today.total_deviation_alerts`
- Chart: `batch_to_batch_deviation_chart.data[]`
- Chart per equipment: `oee_quality_today.by_equipment[]`
#### 2) `POST /api/scada/periodic-report`
**Params minimum (JSON-RPC):**
```json
{
"period": "this_month",
"limit": 1000
}
```
**Field utama untuk UI:**
- KPI produksi: `metrics_total_production.target_qty_total`, `actual_qty_total`, `achievement_percent`
- KPI MO: `metrics_total_mo.total_mo_planned`, `total_mo_done`, `total_mo_in_progress`
- KPI kualitas: `metrics_avg_oee_quality.avg_yield_percent`, `avg_consumption_ratio`
- Chart: `chart_daily_target_vs_actual.data[]`
- Chart: `chart_raw_material_consumption.data[]`
- Table: `table_silo_consumption_stock.data[]`, `table_daily_finished_goods.data[]`
#### 3) `POST /api/scada/oee-equipment-avg`
**Params minimum (JSON-RPC):**
```json
{
"period": "this_month",
"limit": 100,
"offset": 0
}
```
**Field utama untuk UI:**
- List summary: `data[]`
- Nilai utama: `data[].avg_summary.yield_percent`, `consumption_ratio`, `qty_planned`, `qty_finished`
- Detail konsumsi: `data[].avg_consumption_detail.to_consume`, `actual_consumed`, `consumption_ratio`
#### 4) `POST /api/scada/oee-detail`
**Params minimum (JSON-RPC):**
```json
{
"mo_id": "WH/MO/00001",
"limit": 50,
"offset": 0
}
```
**Field utama untuk UI:**
- Header detail: `data[].mo_id`, `product_name`, `equipment.name`, `date_done`
- KPI batch: `data[].yield_percent`, `consumption_ratio`, `variance_finished`, `variance_consumption`
- Tabel/Chart line: `data[].lines[]` (`to_consume`, `actual_consumed`, `variance`, `consumption_ratio`)
#### 5) `POST /api/scada/kpi-product-report`
**Params minimum (JSON-RPC):**
```json
{
"period": "this_month",
"limit": 100,
"offset": 0
}
```
**Field utama untuk UI:**
- KPI global: `summary.total_products`, `summary.total_oee_records`
- Ranking produk: `data[]`
- Nilai KPI produk: `data[].avg_kpi.yield_percent`, `consumption_ratio`, `qty_planned`, `qty_finished`
#### 6) `POST /api/scada/equipment-failure-report`
**Params minimum (JSON-RPC):**
```json
{
"period": "this_month",
"limit": 100,
"offset": 0
}
```
**Field utama untuk UI:**
- KPI: `summary.total_failures`, `summary.equipment_count`
- Chart: `summary.by_equipment[]` (`failure_count`)
- Tabel log: `data[]` (`equipment_name`, `description`, `date`, `duration_minutes`)
#### Template pemanggilan umum (fetch)
```javascript
async function callScadaReport(endpoint, params = {}) {
const res = await fetch(`/api/scada/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params,
}),
});
const payload = await res.json();
if (!res.ok || payload?.status === 'error' || payload?.error) {
throw new Error(payload?.message || payload?.error?.message || 'Request failed');
}
return payload;
}
```
### One-Page Ringkas (Endpoint + Params + Output Wajib)
Gunakan bagian ini untuk handoff cepat ke tim frontend.
| Endpoint | Params Minimum (`params`) | Output Wajib untuk Dashboard |
|---|---|---|
| `POST /api/scada/today-reports` | `{ "date": "YYYY-MM-DD", "limit": 200 }` | `batch_status`, `total_production_today`, `oee_quality_today`, `batch_to_batch_deviation_chart.data[]` |
| `POST /api/scada/periodic-report` | `{ "period": "this_month", "limit": 1000 }` | `metrics_total_production`, `metrics_total_mo`, `metrics_avg_oee_quality`, `chart_daily_target_vs_actual.data[]`, `chart_raw_material_consumption.data[]`, `table_silo_consumption_stock.data[]`, `table_daily_finished_goods.data[]` |
| `POST /api/scada/oee-equipment-avg` | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `data[].equipment`, `data[].avg_summary`, `data[].avg_consumption_detail`, `data[].oee_records_count` |
| `POST /api/scada/oee-detail` | `{ "mo_id": "WH/MO/00001", "limit": 50, "offset": 0 }` | `data[].mo_id`, `data[].product_name`, `data[].yield_percent`, `data[].consumption_ratio`, `data[].lines[]` |
| `POST /api/scada/kpi-product-report` | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `summary.total_products`, `summary.total_oee_records`, `data[].product_name`, `data[].avg_kpi` |
| `POST /api/scada/equipment-failure-report` | `{ "period": "this_month", "limit": 100, "offset": 0 }` | `summary.total_failures`, `summary.equipment_count`, `summary.by_equipment[]`, `data[]` |
**Kontrak request (wajib untuk endpoint `POST` report):**
```json
{
"jsonrpc": "2.0",
"method": "call",
"params": {}
}
```
**Checklist implementasi frontend (minimum):**
1. Selalu kirim `credentials: 'include'`.
2. Selalu validasi `status !== 'error'` sebelum render.
3. Untuk chart utama dashboard, prioritaskan `today-reports` dan `periodic-report`.
4. Endpoint lain dianggap data mentah dan perlu mapping di frontend.
## Frontend Dashboard Quick Reference (Vue.js + ApexCharts)
Bagian ini sudah dipisah per kelompok bab agar implementasi frontend lebih mudah. Setiap bab berisi: endpoint sumber data, field yang ditampilkan, dan rekomendasi visual.
+2 -1
View File
@@ -1,3 +1,4 @@
from . import controllers
from . import hooks
from . import models
from . import wizard
from . import wizard
+4 -1
View File
@@ -1,6 +1,6 @@
{
'name': 'SCADA for Odoo - Manufacturing Integration',
'version': '7.0.86',
'version': '7.0.89',
'category': 'manufacturing',
'license': 'LGPL-3',
'author': 'PT. Gagak Rimang Teknologi',
@@ -13,9 +13,11 @@
'depends': [
'stock',
'mrp',
'maintenance',
'web',
'base',
],
'post_init_hook': 'post_init_hook',
'external_dependencies': {
'python': [
'requests',
@@ -38,6 +40,7 @@
'views/scada_mo_bulk_wizard_view.xml',
# Menus - AFTER all views (references actions from views)
'views/menu.xml',
'views/scada_maintenance_views.xml',
# Data files
'data/demo_data.xml',
'data/ir_cron.xml',
Binary file not shown.
Binary file not shown.
+365 -111
View File
@@ -45,10 +45,33 @@ class ScadaJsonRpcController(http.Controller):
if not value:
return None
cleaned = str(value).strip().replace('T', ' ')
if len(cleaned) == 10:
return f"{cleaned} {'23:59:59' if is_end else '00:00:00'}"
if len(cleaned) == 16:
return f"{cleaned}{':59' if is_end else ':00'}"
if not cleaned:
return None
parse_formats = [
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%d %H:%M',
'%Y-%m-%d',
'%d/%m/%Y %H:%M:%S',
'%d/%m/%Y %H:%M',
'%d/%m/%Y',
]
for datetime_format in parse_formats:
try:
parsed = datetime.strptime(cleaned, datetime_format)
if datetime_format in ('%Y-%m-%d', '%d/%m/%Y'):
if is_end:
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=0)
else:
parsed = parsed.replace(hour=0, minute=0, second=0, microsecond=0)
elif datetime_format in ('%Y-%m-%d %H:%M', '%d/%m/%Y %H:%M'):
parsed = parsed.replace(second=59 if is_end else 0, microsecond=0)
else:
parsed = parsed.replace(microsecond=0)
return parsed.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
continue
return cleaned
def _get_period_datetime_range(self, period):
@@ -147,6 +170,32 @@ class ScadaJsonRpcController(http.Controller):
'last_connected': equipment.last_connected.isoformat() if equipment.last_connected else None,
}
def _read_group_metric(self, row, field_name, aggregate, default=None):
"""Helper: Safely read aggregated value from read_group result.
Odoo read_group keys can vary by version/context:
- field_sum / field_avg / field_max / field_count
- field (some cases)
- __count (for grouped row count)
"""
if not isinstance(row, dict):
return default
candidates = []
if aggregate == 'count':
candidates.extend([f'{field_name}_count', '__count', field_name])
else:
candidates.extend([
f'{field_name}_{aggregate}',
f'{field_name}:{aggregate}',
field_name,
])
for key in candidates:
if key in row and row.get(key) is not None:
return row.get(key)
return default
def _authenticate_session(self, login, password, dbname=None):
db = dbname or request.session.db or request.env.cr.dbname
uid = request.session.authenticate(db, login, password)
@@ -687,8 +736,9 @@ class ScadaJsonRpcController(http.Controller):
- oee_id (optional): specific OEE record id
- mo_id (optional): MO name or ID
- equipment_code (optional): filter by equipment code
- date_from (optional): YYYY-MM-DD
- date_to (optional): YYYY-MM-DD
- date_from (optional): YYYY-MM-DD atau DD/MM/YYYY (boleh datetime)
- date_to (optional): YYYY-MM-DD atau DD/MM/YYYY (boleh datetime)
- period (optional): today, yesterday, this_week, last_7_days, this_month, last_month, this_year
- limit (optional): default 50
- offset (optional): default 0
"""
@@ -736,11 +786,27 @@ class ScadaJsonRpcController(http.Controller):
domain.append(('equipment_id', '=', equipment.id))
date_from = data.get('date_from')
if date_from:
domain.append(('date_done', '>=', f'{date_from} 00:00:00'))
date_to = data.get('date_to')
if date_to:
domain.append(('date_done', '<=', f'{date_to} 23:59:59'))
period = data.get('period')
period_from, period_to = self._get_period_datetime_range(period)
if period_from is False:
return {
'status': 'error',
'message': (
'Invalid period value. '
'Supported: today, yesterday, this_week, last_7_days, '
'this_month, last_month, this_year'
),
}
normalized_from = self._normalize_datetime_input(date_from, is_end=False) or period_from
normalized_to = self._normalize_datetime_input(date_to, is_end=True) or period_to
if normalized_from:
domain.append(('date_done', '>=', normalized_from))
if normalized_to:
domain.append(('date_done', '<=', normalized_to))
records = oee_model.search(
domain,
@@ -801,8 +867,9 @@ class ScadaJsonRpcController(http.Controller):
- equipment_code (optional)
- equipment_type (optional)
- is_active (optional): true/false
- date_from (optional): YYYY-MM-DD
- date_to (optional): YYYY-MM-DD
- date_from (optional): YYYY-MM-DD atau DD/MM/YYYY (boleh datetime)
- date_to (optional): YYYY-MM-DD atau DD/MM/YYYY (boleh datetime)
- period (optional): today, yesterday, this_week, last_7_days, this_month, last_month, this_year
- limit (optional): default 100
- offset (optional): default 0
"""
@@ -846,14 +913,30 @@ class ScadaJsonRpcController(http.Controller):
line_domain = [('equipment_id', 'in', equipment_ids)]
date_from = data.get('date_from')
if date_from:
oee_domain.append(('date_done', '>=', f'{date_from} 00:00:00'))
line_domain.append(('oee_id.date_done', '>=', f'{date_from} 00:00:00'))
date_to = data.get('date_to')
if date_to:
oee_domain.append(('date_done', '<=', f'{date_to} 23:59:59'))
line_domain.append(('oee_id.date_done', '<=', f'{date_to} 23:59:59'))
period = data.get('period')
period_from, period_to = self._get_period_datetime_range(period)
if period_from is False:
return {
'status': 'error',
'message': (
'Invalid period value. '
'Supported: today, yesterday, this_week, last_7_days, '
'this_month, last_month, this_year'
),
}
normalized_from = self._normalize_datetime_input(date_from, is_end=False) or period_from
normalized_to = self._normalize_datetime_input(date_to, is_end=True) or period_to
if normalized_from:
oee_domain.append(('date_done', '>=', normalized_from))
line_domain.append(('oee_id.date_done', '>=', normalized_from))
if normalized_to:
oee_domain.append(('date_done', '<=', normalized_to))
line_domain.append(('oee_id.date_done', '<=', normalized_to))
oee_groups = request.env['scada.equipment.oee'].read_group(
oee_domain,
@@ -880,8 +963,10 @@ class ScadaJsonRpcController(http.Controller):
'qty_to_consume:avg',
'qty_consumed:avg',
'variance_qty:avg',
'oee_silo_percent:avg',
'consumption_ratio:avg',
'material_count:avg',
'oee_id.date_done:max',
],
['equipment_id'],
lazy=False
@@ -901,27 +986,55 @@ class ScadaJsonRpcController(http.Controller):
oee_stat = oee_map.get(equipment.id, {})
line_stat = line_map.get(equipment.id, {})
# For SILO equipment: if no header OEE but has line OEE, count line records
# For Main PLC: use header count
oee_records_count = self._read_group_metric(oee_stat, 'id', 'count', 0)
if oee_records_count == 0 and line_stat:
# SILO case: count number of OEE line records for this equipment
oee_records_count = self._read_group_metric(line_stat, 'id', 'count', 0)
has_header_stats = bool(oee_stat)
if has_header_stats:
yield_percent = self._read_group_metric(oee_stat, 'yield_percent', 'avg', 0.0)
consumption_ratio = self._read_group_metric(oee_stat, 'consumption_ratio', 'avg', 0.0)
else:
# SILO/LQ level stats are stored in line model
yield_percent = self._read_group_metric(line_stat, 'oee_silo_percent', 'avg', 0.0)
consumption_ratio = self._read_group_metric(line_stat, 'consumption_ratio', 'avg', 0.0)
last_oee_date = self._read_group_metric(oee_stat, 'date_done', 'max', None)
if not last_oee_date:
last_oee_date = self._read_group_metric(line_stat, 'oee_id.date_done', 'max', None)
# Return FLAT structure matching frontend mapper expectations (same format as today-reports by_equipment)
result_data.append({
'equipment_id': equipment.id,
'equipment_name': equipment.name,
'oee_records_count': oee_records_count,
'avg_yield_percent': yield_percent,
'avg_consumption_ratio': consumption_ratio,
'avg_oee_silo_percent': yield_percent, # For compatibility
'last_oee_date': last_oee_date,
# Keep nested structure for backward compatibility with API docs
'equipment': self._get_equipment_details(equipment),
'oee_records_count': oee_stat.get('__count', 0),
'avg_summary': {
'qty_planned': oee_stat.get('qty_planned_avg', 0.0),
'qty_finished': oee_stat.get('qty_finished_avg', 0.0),
'variance_finished': oee_stat.get('variance_finished_avg', 0.0),
'yield_percent': oee_stat.get('yield_percent_avg', 0.0),
'qty_bom_consumption': oee_stat.get('qty_bom_consumption_avg', 0.0),
'qty_actual_consumption': oee_stat.get('qty_actual_consumption_avg', 0.0),
'variance_consumption': oee_stat.get('variance_consumption_avg', 0.0),
'consumption_ratio': oee_stat.get('consumption_ratio_avg', 0.0),
'qty_planned': self._read_group_metric(oee_stat, 'qty_planned', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'qty_finished': self._read_group_metric(oee_stat, 'qty_finished', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance_finished': self._read_group_metric(oee_stat, 'variance_finished', 'avg', 0.0) or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'yield_percent': yield_percent,
'qty_bom_consumption': self._read_group_metric(oee_stat, 'qty_bom_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'qty_actual_consumption': self._read_group_metric(oee_stat, 'qty_actual_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance_consumption': self._read_group_metric(oee_stat, 'variance_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'consumption_ratio': consumption_ratio,
},
'avg_consumption_detail': {
'to_consume': line_stat.get('qty_to_consume_avg', 0.0),
'actual_consumed': line_stat.get('qty_consumed_avg', 0.0),
'variance': line_stat.get('variance_qty_avg', 0.0),
'consumption_ratio': line_stat.get('consumption_ratio_avg', 0.0),
'material_count': line_stat.get('material_count_avg', 0.0),
'to_consume': self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'actual_consumed': self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance': self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'consumption_ratio': self._read_group_metric(line_stat, 'consumption_ratio', 'avg', 0.0),
'material_count': self._read_group_metric(line_stat, 'material_count', 'avg', 0.0),
},
'last_oee_date': oee_stat.get('date_done_max'),
})
return {
@@ -1036,18 +1149,18 @@ class ScadaJsonRpcController(http.Controller):
result_data.append({
'product_id': product[0],
'product_name': product[1],
'oee_records_count': row.get('id_count', 0),
'oee_records_count': self._read_group_metric(row, 'id', 'count', 0),
'avg_kpi': {
'qty_planned': row.get('qty_planned_avg', 0.0),
'qty_finished': row.get('qty_finished_avg', 0.0),
'variance_finished': row.get('variance_finished_avg', 0.0),
'yield_percent': row.get('yield_percent_avg', 0.0),
'qty_bom_consumption': row.get('qty_bom_consumption_avg', 0.0),
'qty_actual_consumption': row.get('qty_actual_consumption_avg', 0.0),
'variance_consumption': row.get('variance_consumption_avg', 0.0),
'consumption_ratio': row.get('consumption_ratio_avg', 0.0),
'qty_planned': self._read_group_metric(row, 'qty_planned', 'avg', 0.0),
'qty_finished': self._read_group_metric(row, 'qty_finished', 'avg', 0.0),
'variance_finished': self._read_group_metric(row, 'variance_finished', 'avg', 0.0),
'yield_percent': self._read_group_metric(row, 'yield_percent', 'avg', 0.0),
'qty_bom_consumption': self._read_group_metric(row, 'qty_bom_consumption', 'avg', 0.0),
'qty_actual_consumption': self._read_group_metric(row, 'qty_actual_consumption', 'avg', 0.0),
'variance_consumption': self._read_group_metric(row, 'variance_consumption', 'avg', 0.0),
'consumption_ratio': self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0),
},
'last_oee_date': row.get('date_done_max'),
'last_oee_date': self._read_group_metric(row, 'date_done', 'max', None),
})
return {
@@ -1110,8 +1223,8 @@ class ScadaJsonRpcController(http.Controller):
lazy=False,
)
production_row = production_stats[0] if production_stats else {}
total_production_qty = production_row.get('qty_finished_sum', 0.0)
completed_batch_count = production_row.get('id_count', 0)
total_production_qty = self._read_group_metric(production_row, 'qty_finished', 'sum', 0.0)
completed_batch_count = self._read_group_metric(production_row, 'id', 'count', 0)
# 3) Kualitas OEE rata-rata hari ini per equipment
equipment_avg_rows = oee_model.read_group(
@@ -1128,21 +1241,88 @@ class ScadaJsonRpcController(http.Controller):
['equipment_id'],
lazy=False,
)
line_equipment_domain = [
('oee_id.date_done', '>=', date_from),
('oee_id.date_done', '<=', date_to),
]
line_equipment_avg_rows = request.env['scada.equipment.oee.line'].read_group(
line_equipment_domain,
[
'equipment_id',
'oee_silo_percent:avg',
'consumption_ratio:avg',
'id:count',
],
['equipment_id'],
lazy=False,
)
line_records = request.env['scada.equipment.oee.line'].search(line_equipment_domain)
line_kpi_map = defaultdict(lambda: {
'abs_deviation_sum': 0.0,
'line_count': 0,
'deviation_alert_count': 0,
})
for line in line_records:
if not line.equipment_id:
continue
equipment_kpis = line_kpi_map[line.equipment_id.id]
deviation_abs = abs(line.deviation_percent or 0.0)
equipment_kpis['abs_deviation_sum'] += deviation_abs
equipment_kpis['line_count'] += 1
if deviation_abs > 2.0:
equipment_kpis['deviation_alert_count'] += 1
equipment_avg_map = {
row['equipment_id'][0]: row
for row in equipment_avg_rows if row.get('equipment_id')
}
line_equipment_avg_map = {
row['equipment_id'][0]: row
for row in line_equipment_avg_rows if row.get('equipment_id')
}
combined_equipment_ids = sorted(set(equipment_avg_map.keys()) | set(line_equipment_avg_map.keys()))
equipment_avg = []
for row in equipment_avg_rows:
equipment = row.get('equipment_id')
for equipment_id in combined_equipment_ids:
row = equipment_avg_map.get(equipment_id, {})
line_row = line_equipment_avg_map.get(equipment_id, {})
equipment = row.get('equipment_id') or line_row.get('equipment_id')
if not equipment:
continue
line_kpis = line_kpi_map.get(equipment_id, {})
line_count = line_kpis.get('line_count', 0)
avg_abs_deviation = (
(line_kpis.get('abs_deviation_sum', 0.0) / line_count)
if line_count else 0.0
)
equipment_avg.append({
'equipment_id': equipment[0],
'equipment_name': equipment[1],
'oee_records_count': row.get('id_count', 0),
'avg_yield_percent': row.get('yield_percent_avg', 0.0),
'avg_consumption_ratio': row.get('consumption_ratio_avg', 0.0),
'avg_oee_silo_percent': row.get('avg_silo_oee_percent_avg', 0.0),
'avg_max_abs_deviation_percent': row.get('max_abs_deviation_percent_avg', 0.0),
'total_deviation_alerts': row.get('deviation_alert_count_sum', 0),
'oee_records_count': (
self._read_group_metric(row, 'id', 'count', 0)
or self._read_group_metric(line_row, 'id', 'count', 0)
),
'avg_yield_percent': (
self._read_group_metric(row, 'yield_percent', 'avg', 0.0)
or self._read_group_metric(line_row, 'oee_silo_percent', 'avg', 0.0)
),
'avg_consumption_ratio': (
self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0)
or self._read_group_metric(line_row, 'consumption_ratio', 'avg', 0.0)
),
'avg_oee_silo_percent': (
self._read_group_metric(row, 'avg_silo_oee_percent', 'avg', 0.0)
or self._read_group_metric(line_row, 'oee_silo_percent', 'avg', 0.0)
),
'avg_max_abs_deviation_percent': (
self._read_group_metric(row, 'max_abs_deviation_percent', 'avg', 0.0)
or avg_abs_deviation
),
'total_deviation_alerts': (
self._read_group_metric(row, 'deviation_alert_count', 'sum', 0)
or line_kpis.get('deviation_alert_count', 0)
),
})
equipment_avg.sort(key=lambda item: ((item.get('equipment_name') or '').lower(), item.get('equipment_id') or 0))
oee_quality_rows = oee_model.read_group(
oee_domain,
@@ -1167,6 +1347,18 @@ class ScadaJsonRpcController(http.Controller):
)
deviation_chart = []
for rec in deviation_records:
line_records = rec.line_ids
line_max_abs_deviation = 0.0
line_avg_silo_oee = 0.0
if line_records:
deviations = [abs(line.deviation_percent or 0.0) for line in line_records]
oee_scores = [line.oee_silo_percent or 0.0 for line in line_records]
line_max_abs_deviation = max(deviations) if deviations else 0.0
line_avg_silo_oee = (sum(oee_scores) / len(oee_scores)) if oee_scores else 0.0
max_abs_deviation_percent = rec.max_abs_deviation_percent or line_max_abs_deviation
avg_silo_oee_percent = rec.avg_silo_oee_percent or line_avg_silo_oee
deviation_chart.append({
'oee_id': rec.id,
'mo_id': rec.mo_name,
@@ -1175,8 +1367,8 @@ class ScadaJsonRpcController(http.Controller):
'date_done': rec.date_done.isoformat() if rec.date_done else None,
'variance_finished': rec.variance_finished,
'variance_consumption': rec.variance_consumption,
'max_abs_deviation_percent': rec.max_abs_deviation_percent,
'avg_silo_oee_percent': rec.avg_silo_oee_percent,
'max_abs_deviation_percent': max_abs_deviation_percent,
'avg_silo_oee_percent': avg_silo_oee_percent,
})
return {
@@ -1194,12 +1386,12 @@ class ScadaJsonRpcController(http.Controller):
'completed_batch_count': completed_batch_count,
},
'oee_quality_today': {
'oee_records_count': oee_quality.get('id_count', 0),
'avg_yield_percent': oee_quality.get('yield_percent_avg', 0.0),
'avg_consumption_ratio': oee_quality.get('consumption_ratio_avg', 0.0),
'avg_oee_silo_percent': oee_quality.get('avg_silo_oee_percent_avg', 0.0),
'avg_max_abs_deviation_percent': oee_quality.get('max_abs_deviation_percent_avg', 0.0),
'total_deviation_alerts': oee_quality.get('deviation_alert_count_sum', 0),
'oee_records_count': self._read_group_metric(oee_quality, 'id', 'count', 0),
'avg_yield_percent': self._read_group_metric(oee_quality, 'yield_percent', 'avg', 0.0),
'avg_consumption_ratio': self._read_group_metric(oee_quality, 'consumption_ratio', 'avg', 0.0),
'avg_oee_silo_percent': self._read_group_metric(oee_quality, 'avg_silo_oee_percent', 'avg', 0.0),
'avg_max_abs_deviation_percent': self._read_group_metric(oee_quality, 'max_abs_deviation_percent', 'avg', 0.0),
'total_deviation_alerts': self._read_group_metric(oee_quality, 'deviation_alert_count', 'sum', 0),
'by_equipment': equipment_avg,
},
'batch_to_batch_deviation_chart': {
@@ -1302,7 +1494,8 @@ class ScadaJsonRpcController(http.Controller):
[],
lazy=False,
)
target_total_qty = (mo_target_group[0] if mo_target_group else {}).get('product_qty_sum', 0.0)
mo_target_row = mo_target_group[0] if mo_target_group else {}
target_total_qty = self._read_group_metric(mo_target_row, 'product_qty', 'sum', 0.0)
oee_total_group = request.env['scada.equipment.oee'].read_group(
oee_domain,
@@ -1311,8 +1504,8 @@ class ScadaJsonRpcController(http.Controller):
lazy=False,
)
oee_total_row = oee_total_group[0] if oee_total_group else {}
actual_total_qty = oee_total_row.get('qty_finished_sum', 0.0)
actual_total_target_done = oee_total_row.get('qty_planned_sum', 0.0)
actual_total_qty = self._read_group_metric(oee_total_row, 'qty_finished', 'sum', 0.0)
actual_total_target_done = self._read_group_metric(oee_total_row, 'qty_planned', 'sum', 0.0)
# 2) Metrik Total MO
mo_done_domain = [('date_finished', '>=', date_from), ('date_finished', '<=', date_to)]
@@ -1366,12 +1559,12 @@ class ScadaJsonRpcController(http.Controller):
oee_quality_by_equipment.append({
'equipment_id': equipment[0],
'equipment_name': equipment[1],
'oee_records_count': row.get('id_count', 0),
'avg_yield_percent': row.get('yield_percent_avg', 0.0),
'avg_consumption_ratio': row.get('consumption_ratio_avg', 0.0),
'avg_oee_silo_percent': row.get('avg_silo_oee_percent_avg', 0.0),
'avg_max_abs_deviation_percent': row.get('max_abs_deviation_percent_avg', 0.0),
'total_deviation_alerts': row.get('deviation_alert_count_sum', 0),
'oee_records_count': self._read_group_metric(row, 'id', 'count', 0),
'avg_yield_percent': self._read_group_metric(row, 'yield_percent', 'avg', 0.0),
'avg_consumption_ratio': self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0),
'avg_oee_silo_percent': self._read_group_metric(row, 'avg_silo_oee_percent', 'avg', 0.0),
'avg_max_abs_deviation_percent': self._read_group_metric(row, 'max_abs_deviation_percent', 'avg', 0.0),
'total_deviation_alerts': self._read_group_metric(row, 'deviation_alert_count', 'sum', 0),
})
# 4) Grafik produksi harian dua bar: target vs actual
@@ -1380,20 +1573,31 @@ class ScadaJsonRpcController(http.Controller):
order='date_planned_start asc, id asc',
limit=chart_limit,
)
oee_daily_actual = request.env['scada.equipment.oee'].search(
oee_domain,
order='date_done asc, id asc',
limit=chart_limit,
)
target_by_day = defaultdict(float)
actual_by_day = defaultdict(float)
mo_day_map = {}
for mo in mo_daily_target:
if mo.date_planned_start:
day_key = mo.date_planned_start.date().isoformat()
target_by_day[day_key] += mo.product_qty or 0.0
mo_day_map[mo.id] = day_key
# Align actual to the same planned day axis as target (per MO)
oee_chart_domain = [('manufacturing_order_id', 'in', list(mo_day_map.keys()) or [0])]
if finished_product_ids:
oee_chart_domain.append(('product_id', 'in', finished_product_ids))
if equipment_ids:
oee_chart_domain.append(('equipment_id', 'in', equipment_ids))
oee_daily_actual = request.env['scada.equipment.oee'].search(
oee_chart_domain,
order='date_done asc, id asc',
limit=chart_limit,
)
for rec in oee_daily_actual:
if rec.date_done:
day_key = rec.date_done.date().isoformat()
mo_id = rec.manufacturing_order_id.id if rec.manufacturing_order_id else False
day_key = mo_day_map.get(mo_id)
if day_key:
actual_by_day[day_key] += rec.qty_finished or 0.0
all_days = sorted(set(target_by_day.keys()) | set(actual_by_day.keys()))
@@ -1407,37 +1611,65 @@ class ScadaJsonRpcController(http.Controller):
]
# 5) Grafik konsumsi raw material
material_domain = [
('timestamp', '>=', date_from),
('timestamp', '<=', date_to),
('active', '=', True),
raw_move_domain = [
('state', '=', 'done'),
('raw_material_production_id', '!=', False),
('date', '>=', date_from),
('date', '<=', date_to),
]
if raw_material_ids:
material_domain.append(('product_id', 'in', raw_material_ids))
material_equipment_ids = equipment_ids
if material_equipment_ids:
material_domain.append(('equipment_id', 'in', material_equipment_ids))
raw_move_domain.append(('product_id', 'in', raw_material_ids))
if equipment_ids:
raw_move_domain.append(('scada_equipment_id', 'in', equipment_ids))
if finished_product_ids:
raw_move_domain.append(('raw_material_production_id.product_id', 'in', finished_product_ids))
raw_material_group = request.env['scada.equipment.material'].read_group(
material_domain,
['product_id', 'consumption_actual:sum', 'consumption_bom:sum', 'id:count'],
raw_move_records = request.env['stock.move'].search(raw_move_domain)
raw_move_line_domain = [
('state', '=', 'done'),
('move_id', 'in', raw_move_records.ids or [0]),
]
raw_material_actual_group = request.env['stock.move.line'].read_group(
raw_move_line_domain,
['product_id', 'qty_done:sum', 'id:count'],
['product_id'],
lazy=False,
)
raw_material_bom_group = request.env['stock.move'].read_group(
raw_move_domain,
['product_id', 'product_uom_qty:sum'],
['product_id'],
lazy=False,
)
actual_map = {
row['product_id'][0]: row
for row in raw_material_actual_group if row.get('product_id')
}
bom_map = {
row['product_id'][0]: row
for row in raw_material_bom_group if row.get('product_id')
}
raw_material_chart = []
for row in raw_material_group:
product = row.get('product_id')
material_product_ids = sorted(set(actual_map.keys()) | set(bom_map.keys()))
for product_id in material_product_ids:
actual_row = actual_map.get(product_id, {})
bom_row = bom_map.get(product_id, {})
product = actual_row.get('product_id') or bom_row.get('product_id')
if not product:
continue
actual_cons = row.get('consumption_actual_sum', 0.0)
bom_cons = row.get('consumption_bom_sum', 0.0)
actual_cons = self._read_group_metric(actual_row, 'qty_done', 'sum', 0.0)
bom_cons = self._read_group_metric(bom_row, 'product_uom_qty', 'sum', 0.0)
raw_material_chart.append({
'raw_material_id': product[0],
'raw_material_name': product[1],
'consumption_actual': actual_cons,
'consumption_bom': bom_cons,
'variance_consumption': actual_cons - bom_cons,
'records_count': row.get('id_count', 0),
'records_count': self._read_group_metric(actual_row, 'id', 'count', 0),
})
# 6) Table konsumsi per silo + stock awal/akhir periode
@@ -1447,23 +1679,45 @@ class ScadaJsonRpcController(http.Controller):
silo_equipments = request.env['scada.equipment'].search(silo_domain, order='name asc')
# Pre-calc consumption per equipment-product in period
silo_material_group = request.env['scada.equipment.material'].read_group(
material_domain + [('equipment_id', 'in', silo_equipments.ids or [0])],
['equipment_id', 'product_id', 'consumption_actual:sum', 'id:count'],
['equipment_id', 'product_id'],
silo_raw_move_domain = list(raw_move_domain)
silo_raw_move_domain.append(('scada_equipment_id', 'in', silo_equipments.ids or [0]))
silo_move_records = request.env['stock.move'].search(silo_raw_move_domain)
silo_raw_move_line_domain = [
('state', '=', 'done'),
('move_id', 'in', silo_move_records.ids or [0]),
]
silo_material_group = request.env['stock.move.line'].read_group(
silo_raw_move_line_domain,
['move_id', 'product_id', 'qty_done:sum', 'id:count'],
['move_id', 'product_id'],
lazy=False,
)
move_equipment_map = {
move.id: move.scada_equipment_id.id
for move in silo_move_records if move.scada_equipment_id
}
consumption_map = {}
for row in silo_material_group:
eq = row.get('equipment_id')
move_value = row.get('move_id')
prod = row.get('product_id')
if not eq or not prod:
if not move_value or not prod:
continue
consumption_map[(eq[0], prod[0])] = {
'consumed_qty': row.get('consumption_actual_sum', 0.0),
'records_count': row.get('id_count', 0),
'product_name': prod[1],
}
eq_id = move_equipment_map.get(move_value[0])
if not eq_id:
continue
key = (eq_id, prod[0])
if key not in consumption_map:
consumption_map[key] = {
'consumed_qty': 0.0,
'records_count': 0,
'product_name': prod[1],
}
consumption_map[key]['consumed_qty'] += self._read_group_metric(row, 'qty_done', 'sum', 0.0)
consumption_map[key]['records_count'] += self._read_group_metric(row, 'id', 'count', 0)
# Helpers to reverse stock at period start from current quant + period net movement.
stock_quant_model = request.env['stock.quant']
@@ -1611,12 +1865,12 @@ class ScadaJsonRpcController(http.Controller):
'total_mo_in_progress': total_mo_in_progress,
},
'metrics_avg_oee_quality': {
'oee_records_count': oee_quality_row.get('id_count', 0),
'avg_yield_percent': oee_quality_row.get('yield_percent_avg', 0.0),
'avg_consumption_ratio': oee_quality_row.get('consumption_ratio_avg', 0.0),
'avg_oee_silo_percent': oee_quality_row.get('avg_silo_oee_percent_avg', 0.0),
'avg_max_abs_deviation_percent': oee_quality_row.get('max_abs_deviation_percent_avg', 0.0),
'total_deviation_alerts': oee_quality_row.get('deviation_alert_count_sum', 0),
'oee_records_count': self._read_group_metric(oee_quality_row, 'id', 'count', 0),
'avg_yield_percent': self._read_group_metric(oee_quality_row, 'yield_percent', 'avg', 0.0),
'avg_consumption_ratio': self._read_group_metric(oee_quality_row, 'consumption_ratio', 'avg', 0.0),
'avg_oee_silo_percent': self._read_group_metric(oee_quality_row, 'avg_silo_oee_percent', 'avg', 0.0),
'avg_max_abs_deviation_percent': self._read_group_metric(oee_quality_row, 'max_abs_deviation_percent', 'avg', 0.0),
'total_deviation_alerts': self._read_group_metric(oee_quality_row, 'deviation_alert_count', 'sum', 0),
'by_equipment': oee_quality_by_equipment,
},
'chart_daily_target_vs_actual': {
+8
View File
@@ -0,0 +1,8 @@
from odoo import api, SUPERUSER_ID
def post_init_hook(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
scada_equipment_model = env['scada.equipment']
if scada_equipment_model._maintenance_bridge_column_ready():
scada_equipment_model.search([])._sync_to_maintenance_equipment()
+3
View File
@@ -1,5 +1,6 @@
from . import scada_base
from . import scada_equipment
from . import scada_maintenance_bridge
from . import scada_material_consumption
from . import scada_mo_weight
from . import scada_equipment_material
@@ -15,3 +16,5 @@ from . import mrp_production
from . import mrp_bom_line
from . import stock_move
from . import scada_mo_data_deprecated
from . import maintenance_equipment
from . import maintenance_request
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
from odoo import api, fields, models
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
scada_equipment_id = fields.Many2one(
'scada.equipment',
string='SCADA Equipment',
ondelete='restrict',
index=True,
copy=False,
)
scada_equipment_code = fields.Char(
string='SCADA Code',
related='scada_equipment_id.equipment_code',
store=True,
readonly=True,
)
scada_equipment_type = fields.Selection(
related='scada_equipment_id.equipment_type',
string='SCADA Type',
store=True,
readonly=True,
)
scada_connection_status = fields.Selection(
related='scada_equipment_id.connection_status',
string='SCADA Connection',
readonly=True,
)
_sql_constraints = [
(
'maintenance_equipment_scada_unique',
'unique(scada_equipment_id)',
'Each SCADA equipment can only be linked to one maintenance equipment.',
)
]
@api.onchange('scada_equipment_id')
def _onchange_scada_equipment_id(self):
if self.scada_equipment_id:
self._apply_scada_equipment_values(self.scada_equipment_id)
def _apply_scada_equipment_values(self, scada_equipment):
self.name = scada_equipment.name
self.model = scada_equipment.model_number or False
self.serial_no = scada_equipment.serial_number or False
+14
View File
@@ -0,0 +1,14 @@
from odoo import fields, models
class MaintenanceRequest(models.Model):
_inherit = 'maintenance.request'
scada_equipment_id = fields.Many2one(
'scada.equipment',
related='equipment_id.scada_equipment_id',
string='SCADA Equipment',
store=True,
readonly=True,
)
@@ -0,0 +1,101 @@
from odoo import api, fields, models
class ScadaEquipment(models.Model):
_inherit = 'scada.equipment'
maintenance_equipment_ids = fields.One2many(
'maintenance.equipment',
'scada_equipment_id',
string='Maintenance Equipments',
)
maintenance_request_ids = fields.One2many(
'maintenance.request',
'scada_equipment_id',
string='Maintenance Requests',
)
maintenance_equipment_count = fields.Integer(
compute='_compute_maintenance_counts',
string='Maintenance Equipment Count',
)
maintenance_request_count = fields.Integer(
compute='_compute_maintenance_counts',
string='Maintenance Request Count',
)
def _compute_maintenance_counts(self):
for record in self:
record.maintenance_equipment_count = len(record.maintenance_equipment_ids)
record.maintenance_request_count = len(record.maintenance_request_ids)
def _register_hook(self):
result = super()._register_hook()
if self._maintenance_bridge_column_ready():
self.sudo().search([])._sync_to_maintenance_equipment()
return result
@api.model
def _maintenance_bridge_column_ready(self):
self.env.cr.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = 'maintenance_equipment'
AND column_name = 'scada_equipment_id'
"""
)
return bool(self.env.cr.fetchone())
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._sync_to_maintenance_equipment()
return records
def write(self, vals):
result = super().write(vals)
if any(
field in vals
for field in ('name', 'model_number', 'serial_number', 'is_active')
):
self._sync_to_maintenance_equipment()
return result
def _prepare_maintenance_equipment_vals(self):
self.ensure_one()
return {
'name': self.name,
'model': self.model_number or False,
'serial_no': self.serial_number or False,
'scada_equipment_id': self.id,
'active': self.is_active,
}
def _sync_to_maintenance_equipment(self):
if not self._maintenance_bridge_column_ready():
return
maintenance_equipment_model = self.env['maintenance.equipment']
for record in self:
maintenance_equipment = maintenance_equipment_model.search(
[('scada_equipment_id', '=', record.id)],
limit=1,
)
vals = record._prepare_maintenance_equipment_vals()
if maintenance_equipment:
maintenance_equipment.write(vals)
else:
maintenance_equipment_model.create(vals)
def action_view_maintenance_equipment(self):
self.ensure_one()
action = self.env.ref('maintenance.hr_equipment_action').read()[0]
action['domain'] = [('scada_equipment_id', '=', self.id)]
action['context'] = {'default_scada_equipment_id': self.id}
return action
def action_view_maintenance_requests(self):
self.ensure_one()
action = self.env.ref('maintenance.maintenance_request_action').read()[0]
action['domain'] = [('scada_equipment_id', '=', self.id)]
action['context'] = {'default_scada_equipment_id': self.id}
return action
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_scada_equipment_form_maintenance" model="ir.ui.view">
<field name="name">scada.equipment.form.maintenance</field>
<field name="model">scada.equipment</field>
<field name="inherit_id" ref="grt_scada.view_scada_equipment_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<div class="oe_button_box" name="button_box">
<button
name="action_view_maintenance_equipment"
type="object"
string="Maintenance Equipment"
class="oe_stat_button"
icon="fa-cogs">
<field name="maintenance_equipment_count" widget="statinfo"/>
</button>
<button
name="action_view_maintenance_requests"
type="object"
string="Maintenance Requests"
class="oe_stat_button"
icon="fa-wrench">
<field name="maintenance_request_count" widget="statinfo"/>
</button>
</div>
</xpath>
</field>
</record>
<record id="view_maintenance_equipment_tree_scada" model="ir.ui.view">
<field name="name">maintenance.equipment.tree.scada</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="scada_equipment_code"/>
<field name="scada_equipment_type"/>
</xpath>
</field>
</record>
<record id="view_maintenance_equipment_form_scada" model="ir.ui.view">
<field name="name">maintenance.equipment.form.scada</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="scada_equipment_id" options="{'no_create': True}"/>
</xpath>
<xpath expr="//field[@name='serial_no']" position="after">
<field name="scada_equipment_code" readonly="1"/>
<field name="scada_equipment_type" readonly="1"/>
<field name="scada_connection_status" readonly="1"/>
</xpath>
</field>
</record>
<record id="view_maintenance_equipment_search_scada" model="ir.ui.view">
<field name="name">maintenance.equipment.search.scada</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_search"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="scada_equipment_code"/>
<field name="scada_equipment_id"/>
</xpath>
<xpath expr="//search" position="inside">
<filter
name="filter_scada_equipment"
string="SCADA Equipment"
domain="[('scada_equipment_id', '!=', False)]"/>
</xpath>
</field>
</record>
<record id="view_maintenance_request_tree_scada" model="ir.ui.view">
<field name="name">maintenance.request.tree.scada</field>
<field name="model">maintenance.request</field>
<field name="inherit_id" ref="maintenance.hr_equipment_request_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<field name="scada_equipment_id"/>
</xpath>
</field>
</record>
<record id="view_maintenance_request_form_scada" model="ir.ui.view">
<field name="name">maintenance.request.form.scada</field>
<field name="model">maintenance.request</field>
<field name="inherit_id" ref="maintenance.hr_equipment_request_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='equipment_id']" position="after">
<field name="scada_equipment_id" readonly="1"/>
</xpath>
</field>
</record>
<record id="view_maintenance_request_search_scada" model="ir.ui.view">
<field name="name">maintenance.request.search.scada</field>
<field name="model">maintenance.request</field>
<field name="inherit_id" ref="maintenance.hr_equipment_request_view_search"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<field name="scada_equipment_id"/>
<filter
name="filter_scada_request"
string="SCADA Equipment"
domain="[('scada_equipment_id', '!=', False)]"/>
</xpath>
</field>
</record>
<record id="action_scada_maintenance_equipment" model="ir.actions.act_window">
<field name="name">Maintenance Equipment</field>
<field name="res_model">maintenance.equipment</field>
<field name="view_mode">tree,kanban,form,pivot,graph</field>
<field name="domain">[('scada_equipment_id', '!=', False)]</field>
<field name="context">{'search_default_filter_scada_equipment': 1}</field>
</record>
<record id="action_scada_maintenance_request" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">tree,kanban,form,pivot,graph</field>
<field name="domain">[('scada_equipment_id', '!=', False)]</field>
<field name="context">{'search_default_filter_scada_request': 1}</field>
</record>
<menuitem
id="menu_scada_maintenance"
parent="grt_scada.menu_scada_root"
name="Maintenance"
sequence="46"/>
<menuitem
id="menu_scada_maintenance_equipment"
parent="menu_scada_maintenance"
action="action_scada_maintenance_equipment"
name="Equipment"
sequence="1"/>
<menuitem
id="menu_scada_maintenance_request"
parent="menu_scada_maintenance"
action="action_scada_maintenance_request"
name="Requests"
sequence="2"/>
</odoo>
Binary file not shown.
+847 -8379
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+11
View File
@@ -8,7 +8,9 @@ class KpiPeriod(models.Model):
_name = "kpi.period"
_description = "KPI Period"
_order = "year desc, month desc, id desc"
_rec_name = "name"
name = fields.Char(compute="_compute_name", store=True, index=True)
year = fields.Integer(required=True, default=lambda self: fields.Date.today().year)
month = fields.Integer(required=True, default=lambda self: fields.Date.today().month)
date_start = fields.Date(required=True)
@@ -34,3 +36,12 @@ class KpiPeriod(models.Model):
rec.date_start = date(rec.year, rec.month, 1)
rec.date_end = date(rec.year, rec.month, last_day)
@api.depends("year", "month")
def _compute_name(self):
for rec in self:
if rec.year and rec.month and 1 <= rec.month <= 12:
rec.name = "%s %s" % (calendar.month_name[rec.month], rec.year)
elif rec.year and rec.month:
rec.name = "%s/%s" % (rec.month, rec.year)
else:
rec.name = "-"
+112
View File
@@ -0,0 +1,112 @@
# Test script untuk verify SILO equipment OEE records setelah deployment
# Jalankan: .\test_silo_oee_after_deployment.ps1
$base = 'https://kanjabung.web.id'
$db = 'kanjabung_MRP'
$login = 'admin'
$password = 'admin'
Write-Host "=== SILO OEE Equipment Verification Test ===" -ForegroundColor Cyan
Write-Host "Server: $base" -ForegroundColor Gray
Write-Host "Database: $db" -ForegroundColor Gray
Write-Host ""
# Authenticate
Write-Host "[1/3] Authenticating..." -ForegroundColor Yellow
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$authBody = @{
jsonrpc = '2.0'
method = 'call'
params = @{
db = $db
login = $login
password = $password
}
} | ConvertTo-Json -Depth 6
try {
$authResp = Invoke-RestMethod -Uri "$base/web/session/authenticate" `
-Method Post `
-ContentType 'application/json' `
-Body $authBody `
-WebSession $session `
-TimeoutSec 60
Write-Host "✓ Authentication successful (UID: $($authResp.result.uid))" -ForegroundColor Green
} catch {
Write-Host "✗ Authentication failed: $_" -ForegroundColor Red
exit 1
}
# Test OEE equipment avg endpoint with date range
Write-Host "[2/3] Fetching OEE equipment data (24/02/2026 - 13/03/2026)..." -ForegroundColor Yellow
$payload = @{
jsonrpc = '2.0'
method = 'call'
params = @{
date_from = '2026-02-24'
date_to = '2026-03-13'
limit = 100
offset = 0
}
} | ConvertTo-Json -Depth 8
try {
$response = Invoke-RestMethod -Uri "$base/api/scada/oee-equipment-avg" `
-Method Post `
-ContentType 'application/json' `
-Body $payload `
-WebSession $session `
-TimeoutSec 60
Write-Host "✓ API call successful" -ForegroundColor Green
Write-Host ""
# Show summary
$total = $response.result.count
$equipWithData = @($response.result.data | Where-Object { $_.oee_records_count -gt 0 })
Write-Host "=== RESULTS ===" -ForegroundColor Cyan
Write-Host "Total Equipment: $total"
Write-Host "Equipment with OEE records: $($equipWithData.Count)"
Write-Host ""
# Show equipment with OEE records
Write-Host "Equipment with OEE Data:" -ForegroundColor Yellow
$equipWithData | ForEach-Object {
$name = $_.equipment.name
$code = $_.equipment.code
$count = $_.oee_records_count
$yield = [math]::Round($_.avg_summary.yield_percent, 2)
Write-Host "$name ($code)" -ForegroundColor Cyan
Write-Host " - OEE Records: $count" -ForegroundColor Gray
Write-Host " - Avg Yield: $yield%" -ForegroundColor Gray
}
Write-Host ""
Write-Host "=== VERIFICATION ===" -ForegroundColor Cyan
# Check if SILO equipment have records
$siloEquip = @($equipWithData | Where-Object { $_.equipment.code -match 'silo' })
$lqEquip = @($equipWithData | Where-Object { $_.equipment.code -match 'lq' })
$plcEquip = @($equipWithData | Where-Object { $_.equipment.code -match 'plc' })
Write-Host "SILO equipment with data: $($siloEquip.Count)" -ForegroundColor $(if($siloEquip.Count -gt 0) { 'Green' } else { 'Yellow' })
Write-Host "LQ equipment with data: $($lqEquip.Count)" -ForegroundColor $(if($lqEquip.Count -gt 0) { 'Green' } else { 'Yellow' })
Write-Host "PLC equipment with data: $($plcEquip.Count)" -ForegroundColor $(if($plcEquip.Count -gt 0) { 'Green' } else { 'Yellow' })
if ($siloEquip.Count -gt 0) {
Write-Host ""
Write-Host "✓ SUCCESS: SILO equipment sekarang muncul dengan OEE records!" -ForegroundColor Green
} else {
Write-Host ""
Write-Host "⚠ WARNING: SILO equipment masih belum ada OEE records" -ForegroundColor Yellow
Write-Host " Kemungkinan: deployment maybe belum selesai atau code belum di-reload" -ForegroundColor Gray
}
} catch {
Write-Host "✗ API call failed: $_" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "[3/3] Test completed" -ForegroundColor Yellow
+378
View File
@@ -0,0 +1,378 @@
from pathlib import Path
import xlsxwriter
OUTPUT_PATH = Path("odoo_kpi") / "kpi_weight_sample.xlsx"
def main() -> None:
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
workbook = xlsxwriter.Workbook(str(OUTPUT_PATH))
workbook.set_properties(
{
"title": "KPI Weight Sample",
"subject": "Contoh perhitungan bobot KPI employee",
"author": "Codex",
"comments": "Generated from odoo_kpi formula",
}
)
title_fmt = workbook.add_format(
{
"bold": True,
"font_size": 14,
"bg_color": "#D9EAF7",
"border": 1,
"align": "center",
"valign": "vcenter",
}
)
header_fmt = workbook.add_format(
{
"bold": True,
"bg_color": "#B7DEE8",
"border": 1,
"align": "center",
"valign": "vcenter",
}
)
text_fmt = workbook.add_format({"border": 1, "valign": "top"})
center_fmt = workbook.add_format({"border": 1, "align": "center", "valign": "vcenter"})
num_fmt = workbook.add_format({"border": 1, "num_format": "0.00"})
pct_fmt = workbook.add_format({"border": 1, "num_format": "0.00%"})
note_fmt = workbook.add_format(
{
"text_wrap": True,
"valign": "top",
"border": 1,
}
)
total_label_fmt = workbook.add_format(
{
"bold": True,
"bg_color": "#E2F0D9",
"border": 1,
}
)
total_num_fmt = workbook.add_format(
{
"bold": True,
"bg_color": "#E2F0D9",
"border": 1,
"num_format": "0.00",
}
)
section_fmt = workbook.add_format(
{
"bold": True,
"font_size": 12,
"bg_color": "#FCE4D6",
"border": 1,
}
)
info_fmt = workbook.add_format(
{
"text_wrap": True,
"border": 1,
"valign": "top",
"bg_color": "#FFF2CC",
}
)
input_fmt = workbook.add_format(
{
"border": 1,
"bg_color": "#FFF2CC",
"num_format": "0.00",
}
)
grade_fmt = workbook.add_format(
{
"bold": True,
"font_size": 12,
"align": "center",
"valign": "vcenter",
"border": 1,
"bg_color": "#E2F0D9",
}
)
subtitle_fmt = workbook.add_format(
{
"italic": True,
"font_color": "#555555",
}
)
sheet = workbook.add_worksheet("Sample KPI")
sheet.set_column("A:A", 8)
sheet.set_column("B:B", 24)
sheet.set_column("C:F", 14)
sheet.set_column("G:H", 18)
sheet.set_column("I:I", 16)
sheet.set_column("J:J", 42)
sheet.freeze_panes(3, 0)
sheet.merge_range("A1:J1", "Sample Perhitungan Bobot KPI Employee", title_fmt)
sheet.merge_range(
"A2:J2",
"Formula modul odoo_kpi: Total Score = SUM((Actual / Target) x Weight) untuk semua assignment employee dalam 1 periode.",
info_fmt,
)
sheet.write_row(
"A3",
[
"No",
"KPI",
"Target",
"Actual",
"Weight",
"Achievement",
"Nilai Assignment",
"Kontribusi ke Total",
"Status",
"Catatan",
],
header_fmt,
)
rows = [
(
1,
"Sales",
100,
80,
50,
"=(D4/C4)",
"=(D4/C4)*E4",
"=G4/$G$8",
'=IF(D4>C4,"Over target",IF(D4=C4,"On target","Below target"))',
"Rumus Odoo: (actual / target) x weight",
),
(
2,
"Visit",
20,
15,
30,
"=(D5/C5)",
"=(D5/C5)*E5",
"=G5/$G$8",
'=IF(D5>C5,"Over target",IF(D5=C5,"On target","Below target"))',
"Kontribusi dihitung dari nilai assignment dibagi total score employee",
),
(
3,
"Collection",
10,
12,
20,
"=(D6/C6)",
"=(D6/C6)*E6",
"=G6/$G$8",
'=IF(D6>C6,"Over target",IF(D6=C6,"On target","Below target"))',
"Tidak ada cap di kode, jadi over target bisa melampaui bobot",
),
]
start_row = 3
for row_idx, row in enumerate(rows, start=start_row):
sheet.write_number(row_idx, 0, row[0], center_fmt)
sheet.write_string(row_idx, 1, row[1], text_fmt)
sheet.write_number(row_idx, 2, row[2], num_fmt)
sheet.write_number(row_idx, 3, row[3], num_fmt)
sheet.write_number(row_idx, 4, row[4], num_fmt)
sheet.write_formula(row_idx, 5, row[5], pct_fmt)
sheet.write_formula(row_idx, 6, row[6], num_fmt)
sheet.write_formula(row_idx, 7, row[7], pct_fmt)
sheet.write_formula(row_idx, 8, row[8], center_fmt)
sheet.write_string(row_idx, 9, row[9], note_fmt)
sheet.write("F8", "Total Score", total_label_fmt)
sheet.write_formula("G8", "=SUM(G4:G6)", total_num_fmt)
sheet.write("F9", "Total Weight", total_label_fmt)
sheet.write_formula("G9", "=SUM(E4:E6)", total_num_fmt)
sheet.write("F10", "Grade", total_label_fmt)
sheet.write_formula(
"G10",
'=IF(G8>=90,"A",IF(G8>=75,"B",IF(G8>=60,"C",IF(G8>=40,"D","E"))))',
grade_fmt,
)
sheet.write("H8", "Interpretasi", total_label_fmt)
sheet.write_formula(
"I8",
'=IF(G8>=90,"Sangat baik",IF(G8>=75,"Baik",IF(G8>=60,"Cukup",IF(G8>=40,"Kurang","Buruk"))))',
total_label_fmt,
)
sheet.write("H9", "Catatan", total_label_fmt)
sheet.merge_range(
"I9:J10",
"Jika total weight = 100 maka total score terasa seperti skala 0-100. Jika actual > target, nilai assignment bisa melebihi bobot karena rumus belum di-cap.",
note_fmt,
)
sheet.write("A12", "Cara pakai:", header_fmt)
instructions = [
"Ubah kolom Target, Actual, atau Weight untuk simulasi KPI lain.",
"Achievement = Actual / Target.",
"Nilai Assignment = Achievement x Weight.",
"Kontribusi ke Total = Nilai Assignment / Total Score.",
"Jika total weight = 100, total score akan terasa seperti nilai skala 0-100.",
"Jika actual melebihi target, nilai assignment bisa melebihi bobot karena kode saat ini tidak membatasi hasil.",
]
for idx, instruction in enumerate(instructions, start=12):
sheet.write(f"A{idx}", f"{idx - 11}.", text_fmt)
sheet.merge_range(f"B{idx}:J{idx}", instruction, note_fmt)
summary_sheet = workbook.add_worksheet("Executive Summary")
summary_sheet.set_column("A:A", 24)
summary_sheet.set_column("B:B", 18)
summary_sheet.set_column("C:C", 40)
summary_sheet.freeze_panes(4, 0)
summary_sheet.merge_range("A1:C1", "Ringkasan Untuk HR / Manager", title_fmt)
summary_sheet.write("A3", "Area", header_fmt)
summary_sheet.write("B3", "Nilai", header_fmt)
summary_sheet.write("C3", "Keterangan", header_fmt)
summary_sheet.write("A4", "Total Score Employee", text_fmt)
summary_sheet.write_formula("B4", "='Sample KPI'!G8", total_num_fmt)
summary_sheet.write("C4", "Total nilai akhir employee untuk periode tersebut.", note_fmt)
summary_sheet.write("A5", "Grade", text_fmt)
summary_sheet.write_formula("B5", "='Sample KPI'!G10", grade_fmt)
summary_sheet.write("C5", "Grade mengikuti mapping A/B/C/D/E dari modul.", note_fmt)
summary_sheet.write("A6", "Total Weight", text_fmt)
summary_sheet.write_formula("B6", "='Sample KPI'!G9", total_num_fmt)
summary_sheet.write("C6", "Idealnya 100 jika ingin score langsung terbaca sebagai persentase performa.", note_fmt)
summary_sheet.write("A8", "KPI Dominan", header_fmt)
summary_sheet.write("B8", "Kontribusi", header_fmt)
summary_sheet.write("C8", "Makna", header_fmt)
summary_sheet.write_formula("A9", '=INDEX(\'Sample KPI\'!B4:B6, MATCH(MAX(\'Sample KPI\'!H4:H6), \'Sample KPI\'!H4:H6, 0))', text_fmt)
summary_sheet.write_formula("B9", '=MAX(\'Sample KPI\'!H4:H6)', pct_fmt)
summary_sheet.write("C9", "Assignment dengan sumbangan terbesar ke total nilai akhir.", note_fmt)
summary_sheet.merge_range(
"A11:C14",
"Baca report ini seperti berikut: bobot menunjukkan porsi maksimum kontribusi KPI, tetapi nilai akhir tetap tergantung pencapaian actual terhadap target. KPI dengan bobot besar tetapi actual rendah tetap memberi kontribusi kecil.",
info_fmt,
)
override_sheet = workbook.add_worksheet("Override Example")
override_sheet.set_column("A:A", 24)
override_sheet.set_column("B:C", 16)
override_sheet.set_column("D:D", 38)
override_sheet.freeze_panes(3, 0)
override_sheet.merge_range("A1:D1", "Contoh Target Override dan Weight Override", title_fmt)
override_sheet.write("A2", "Override dipakai lebih dulu daripada default target/weight KPI.", subtitle_fmt)
override_sheet.write_row("A3", ["Parameter", "Nilai", "Dipakai?", "Catatan"], header_fmt)
override_rows = [
("Default Target KPI", 100, "Tidak", "Akan kalah prioritas jika target_override diisi"),
("Default Weight KPI", 40, "Tidak", "Akan kalah prioritas jika weight_override diisi"),
("Target Override", 80, "Ya", "Dipakai sebagai effective_target"),
("Weight Override", 50, "Ya", "Dipakai sebagai effective_weight"),
("Actual", 60, "Ya", "Total dari semua kpi.value untuk assignment ini"),
]
for idx, row in enumerate(override_rows, start=3):
override_sheet.write_string(idx, 0, row[0], text_fmt)
override_sheet.write_number(idx, 1, row[1], num_fmt)
override_sheet.write_string(idx, 2, row[2], text_fmt)
override_sheet.write_string(idx, 3, row[3], note_fmt)
override_sheet.write("A10", "Rumus", total_label_fmt)
override_sheet.write_formula("B10", "=(B7/B5)*B6", total_num_fmt)
override_sheet.write("C10", "Hasil", total_label_fmt)
override_sheet.write("D10", "Nilai assignment = (60 / 80) x 50 = 37.5", note_fmt)
sim_sheet = workbook.add_worksheet("Simulator")
sim_sheet.set_column("A:A", 8)
sim_sheet.set_column("B:B", 24)
sim_sheet.set_column("C:F", 14)
sim_sheet.set_column("G:H", 18)
sim_sheet.set_column("I:J", 24)
sim_sheet.freeze_panes(5, 0)
sim_sheet.merge_range("A1:J1", "Simulator KPI Employee", title_fmt)
sim_sheet.merge_range(
"A2:J2",
"Kolom kuning bisa diubah. Rumus akan otomatis menghitung nilai assignment, kontribusi, total score, dan grade.",
info_fmt,
)
sim_sheet.write("A4", "Employee Name", header_fmt)
sim_sheet.merge_range("B4:D4", "Nama Employee", info_fmt)
sim_sheet.write("E4", "Periode", header_fmt)
sim_sheet.merge_range("F4:G4", "2026-03", info_fmt)
sim_sheet.write_row(
"A5",
[
"No",
"KPI",
"Target",
"Actual",
"Weight",
"Achievement",
"Nilai Assignment",
"Kontribusi",
"Catatan",
"Status",
],
header_fmt,
)
sim_rows = range(6, 14)
for excel_row, seq in zip(sim_rows, range(1, 9)):
sim_sheet.write_number(excel_row - 1, 0, seq, center_fmt)
sim_sheet.write_blank(excel_row - 1, 1, "", text_fmt)
sim_sheet.write_blank(excel_row - 1, 2, "", input_fmt)
sim_sheet.write_blank(excel_row - 1, 3, "", input_fmt)
sim_sheet.write_blank(excel_row - 1, 4, "", input_fmt)
sim_sheet.write_formula(
excel_row - 1,
5,
f'=IFERROR(D{excel_row}/C{excel_row},0)',
pct_fmt,
)
sim_sheet.write_formula(
excel_row - 1,
6,
f'=IF(OR(C{excel_row}<=0,E{excel_row}<=0),0,(D{excel_row}/C{excel_row})*E{excel_row})',
num_fmt,
)
sim_sheet.write_formula(
excel_row - 1,
7,
f'=IFERROR(G{excel_row}/$G$16,0)',
pct_fmt,
)
sim_sheet.write_blank(excel_row - 1, 8, "", note_fmt)
sim_sheet.write_formula(
excel_row - 1,
9,
f'=IF(G{excel_row}=0,"No score",IF(D{excel_row}>C{excel_row},"Over target",IF(D{excel_row}=C{excel_row},"On target","Below target")))',
center_fmt,
)
sim_sheet.write("F16", "Total Score", total_label_fmt)
sim_sheet.write_formula("G16", "=SUM(G6:G13)", total_num_fmt)
sim_sheet.write("F17", "Total Weight", total_label_fmt)
sim_sheet.write_formula("G17", "=SUM(E6:E13)", total_num_fmt)
sim_sheet.write("F18", "Grade", total_label_fmt)
sim_sheet.write_formula(
"G18",
'=IF(G16>=90,"A",IF(G16>=75,"B",IF(G16>=60,"C",IF(G16>=40,"D","E"))))',
grade_fmt,
)
sim_sheet.write("H16", "Interpretasi", total_label_fmt)
sim_sheet.write_formula(
"I16",
'=IF(G16>=90,"Sangat baik",IF(G16>=75,"Baik",IF(G16>=60,"Cukup",IF(G16>=40,"Kurang","Buruk"))))',
total_label_fmt,
)
sim_sheet.merge_range(
"H17:J18",
"Tips: set total bobot ke 100. Jika total bobot tidak 100, score akhir tetap valid menurut kode, tetapi lebih sulit dibaca sebagai skala 0-100.",
note_fmt,
)
workbook.close()
if __name__ == "__main__":
main()