commit b47e392df3926002a48e2e4bf75c8bed777cd46d Author: Madanil Date: Wed Oct 28 13:45:42 2026 +0700 [UPD] initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdeedaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +docs +gmn_freshservice/tests \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..03a07b2 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Generator Laporan Analisis Tiket (GMN Freshservice) + +| Atribut | Nilai | +|---|---| +| **Versi** | 1.0.0 | +| **Tanggal** | 20 Oktober 2026 | +| **Status** | Disetujui (Approved) | +| **Pembaca** | Semua Tim (Manajemen, Service Desk, Developer, QA) | +| **Pembaruan Terakhir** | Saat penyesuaian aturan klasifikasi SLA | + +> **Intinya dalam 3 kalimat:** +> Aplikasi ini membaca riwayat aktivitas (audit trail) tiket Freshservice dan mengubahnya menjadi laporan Excel 3 sheet yang adil dan transparan. Sistem menghitung secara otomatis berapa lama waktu pengerjaan di pihak IT, waktu tunggu di pihak pemohon/user, waktu vendor, dan waktu rapat bersama. Dengan laporan ini, evaluasi SLA tidak lagi saling menyalahkan karena setiap menit tercatat lengkap dengan bukti pesan dan statusnya. + +--- + +## 1. Daftar Dokumen & Panduan Membaca +Seluruh dokumentasi teknis, bisnis, operasional, dan pengujian tersimpan rapi di dalam folder `docs/`: + +| Kategori | Dokumen Kunci | Keterangan | +|---|---|---| +| **Daftar Isi & Indeks** | [`docs/00-INDEX.md`](docs/00-INDEX.md) | Peta navigasi seluruh dokumen dan urutan baca pemula | +| **Bisnis & Kebutuhan** | [`docs/01-bisnis/BRD.md`](docs/01-bisnis/BRD.md)
[`docs/02-kebutuhan/SRS.md`](docs/02-kebutuhan/SRS.md)
[`docs/02-kebutuhan/Katalog-Aturan-Bisnis.md`](docs/02-kebutuhan/Katalog-Aturan-Bisnis.md) | Alasan proyek dibuat, spesifikasi kebutuhan FR/NFR, dan katalog aturan RULE-01 s/d RULE-12 | +| **Desain Teknis & Data** | [`docs/03-desain/FDD.md`](docs/03-desain/FDD.md)
[`docs/03-desain/TDD.md`](docs/03-desain/TDD.md)
[`docs/03-desain/Arsitektur.md`](docs/03-desain/Arsitektur.md)
[`docs/03-desain/Kamus-Data-dan-DFD.md`](docs/03-desain/Kamus-Data-dan-DFD.md)
[`docs/03-desain/ADR/`](docs/03-desain/ADR/) | Desain fungsional, arsitektur modul Odoo, kamus kolom Excel, dan catatan keputusan arsitektur (ADR-01 s/d ADR-03) | +| **Pengujian & Validasi** | [`docs/04-pengujian/Test-Plan.md`](docs/04-pengujian/Test-Plan.md)
[`docs/04-pengujian/Test-Case.md`](docs/04-pengujian/Test-Case.md)
[`docs/04-pengujian/Laporan-Validasi.md`](docs/04-pengujian/Laporan-Validasi.md)
[`docs/04-pengujian/UAT.md`](docs/04-pengujian/UAT.md) | Rencana uji, skenario uji TC-01 s/d TC-08, bukti validasi Tiket #9658, dan lembar UAT pengguna | +| **Operasional & Panduan** | [`docs/05-operasional/Panduan-Pengguna.md`](docs/05-operasional/Panduan-Pengguna.md)
[`docs/05-operasional/SOP-Status-Tiket.md`](docs/05-operasional/SOP-Status-Tiket.md)
[`docs/05-operasional/Runbook.md`](docs/05-operasional/Runbook.md)
[`docs/05-operasional/Konfigurasi.md`](docs/05-operasional/Konfigurasi.md) | Panduan langkah demi langkah agen/supervisor, SOP pemilihan status, instalasi, dan parameter konfigurasi | +| **Manajemen & Tata Kelola**| [`docs/06-manajemen/Risiko-dan-Known-Issues.md`](docs/06-manajemen/Risiko-dan-Known-Issues.md)
[`docs/06-manajemen/Change-Request-Perbaikan-Logic.md`](docs/06-manajemen/Change-Request-Perbaikan-Logic.md)
[`docs/06-manajemen/Keamanan-dan-Privasi.md`](docs/06-manajemen/Keamanan-dan-Privasi.md)
[`docs/06-manajemen/Traceability-Matrix.md`](docs/06-manajemen/Traceability-Matrix.md)
[`docs/06-manajemen/Roadmap.md`](docs/06-manajemen/Roadmap.md)
[`docs/06-manajemen/Glosarium.md`](docs/06-manajemen/Glosarium.md)
[`CHANGELOG.md`](CHANGELOG.md) | Daftar risiko, analisis dampak CR-01, matriks ketelusuran BR-FR-RULE-TC, peta jalan rilis, dan glosarium istilah | + +--- + +## 2. Fitur Utama +1. **Two-Tier Synchronization**: Tarik daftar tiket secara instan tanpa freeze, dan ambil rincian lengkap (aktivitas audit trail + riwayat chat) secara on-demand saat tiket dibuka. +2. **Mesin Hitung Jam Kerja Efektif**: + - Hari Kerja: Senin – Jumat (08:30 – 17:30 WIB). + - Istirahat: 12:30 – 13:30 WIB (tidak dihitung sebagai jam kerja). + - Libur Nasional: Terintegrasi dengan kalender libur Indonesia (2024–2026) dan kalender Odoo. + - Konversi 8 jam kerja efektif = 1 hari kerja. +3. **Klasifikasi Tanggung Jawab Adil (Fair Responsibility Logic)**: + - Membagi waktu tiket ke dalam 8 kategori (*bucket*). + - Deteksi otomatis balasan agen (*Ball-in-Court Analysis*) untuk menandai status ambigu (`needs_verification`). + - Deteksi undangan rapat Zoom/Teams otomatis ke bucket *Scheduled Meeting (Bersama)*. + - Pemisahan durasi *Resolved* oleh bot otomatis sistem (`user_after_auto_resolve`) agar tidak menyalahkan user secara keliru. +4. **Generator Laporan Excel 3 Sheet**: + - **Sheet 1 (Ringkasan & Bottleneck KPI)**: Kartu KPI, breakdown persentase tanggung jawab, dan tabel ranking segmen bottleneck terlama. + - **Sheet 2 (Audit Trail & Chronology)**: Rekonstruksi kronologis setiap menit aktivitas tiket dengan formula total otomatis. + - **Sheet 3 (Conversations & Notes)**: Transkrip lengkap percakapan dan catatan tiket antara agen dan pemohon. +5. **Dukungan Multi-Tiket**: Ekspor perbandingan ranking bottleneck untuk bahan rapat koordinasi mingguan tim Service Desk. + +--- + +## 3. Panduan Cepat (Quick Start dalam 15 Menit) + +### 3.1 Prasyarat +- Odoo 19.0 Community / Enterprise (Python 3.10+) +- Modul python `xlsxwriter` dan `requests` terpasang di environment Odoo +- Akun admin Freshservice dengan hak akses API (API Key) + +### 3.2 Konfigurasi Koneksi +1. Masuk ke Odoo dengan hak akses Administrator. +2. Buka menu **Settings** > **Freshservice Configuration**. +3. Masukkan URL Freshservice (contoh: `https://servicedesk.perusahaan.co.id`) dan **API Key**. +4. Atur batas sinkronisasi (default: `200` tiket). +5. Klik tombol **Uji Koneksi** untuk memastikan status terhubung. + +### 3.3 Menjalankan Sinkronisasi & Analisis +1. Buka menu **Freshservice** > **Daftar Tiket**. +2. Klik tombol **Tarik Tiket Terbaru** pada bagian atas tabel. +3. Buka salah satu tiket yang ingin dianalisis (misal: Tiket #9658). +4. Klik tombol **Analisis Bottleneck (Excel)** di pojok kanan atas formulir tiket. +5. Pilih tipe laporan: *Analisis Detail Bottleneck Tiket Tunggal*. +6. Klik **Unduh Laporan Excel**. File `.xlsx` 3 sheet akan otomatis diunduh ke komputer Anda. + +--- + +## 4. Struktur Folder Modul +```text +gmn_module_v2/ +├── README.md # Dokumentasi utama proyek ini +├── CHANGELOG.md # Catatan rilis dan riwayat perubahan +├── docs/ # Folder seluruh dokumen pendukung +│ ├── 00-INDEX.md # Indeks dokumen dan panduan membaca +│ ├── 01-bisnis/ # BRD (Business Requirements Document) +│ ├── 02-kebutuhan/ # SRS & Katalog Aturan Bisnis +│ ├── 03-desain/ # FDD, TDD, Arsitektur, Kamus Data, ADR +│ ├── 04-pengujian/ # Test Plan, Test Case, Laporan Validasi, UAT +│ ├── 05-operasional/ # Panduan Pengguna, SOP, Runbook, Konfigurasi +│ └── 06-manajemen/ # Risiko, Change Request, Traceability Matrix, Glosarium +└── gmn_freshservice/ # Kode sumber addon Odoo 19 + ├── __init__.py + ├── __manifest__.py + ├── models/ # Model Odoo: ticket, stage_log, conversation, settings + ├── services/ # Client integrasi Freshservice REST API v2 + ├── wizard/ # Wizard export laporan Excel via xlsxwriter + ├── views/ # XML view Odoo (form, list, settings, menu) + ├── data/ # Demo data & konfigurasi default + ├── security/ # Hak akses ir.model.access.csv + └── tests/ # Unit test otomatis & validasi acceptance +``` + +--- + +## 5. Catatan Penulisan & Verifikasi Integritas (Self-Check) + +1. **Daftar File Kode Sumber yang Dianalisis**: + - `gmn_freshservice/__manifest__.py` + - `gmn_freshservice/models/gmn_freshservice_ticket_v01.py` + - `gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py` + - `gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py` + - `gmn_freshservice/models/gmn_res_config_settings_v01.py` + - `gmn_freshservice/services/gmn_freshservice_client_v01.py` + - `gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py` + - `gmn_freshservice/tests/test_responsibility_rules.py` +2. **Daftar Item [PERLU KONFIRMASI]**: + - PK-01: Nama resmi departemen pemohon SLS apakah ada kepanjangan divisi spesifik di luar data Freshservice? (Asumsi saat ini: SLS merepresentasikan departemen operasional pemohon). + - PK-02: Jadwal rilis resmi ke server staging user apakah mengikuti jadwal sprint dua mingguan tim IT? + - PK-03: Kebijakan retensi data teks pesan percakapan di database Odoo lokal (apakah dipangkas setelah 1 tahun atau disimpan selamanya). +3. **Daftar Aturan yang Semula Direncanakan [PLANNED] dan Telah Berhasil [IMPLEMENTED] di Kode**: + - Aturan A (Sticky State & Event Status Eksplisit): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`. + - Aturan B (Ball-in-Court Analysis & Deteksi Ambiguitas): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::_ball_in_court_check`. + - Aturan C (User Setelah Auto-Resolve Sistem): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`. + - Aturan D (Deteksi Rapat Bersama & Pembatalan Zoom): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`. + - Aturan E (First Ack vs First Meaningful Response): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`. + - Aturan F (Override Manual & Cut-off as_of): Telah diimplementasikan di `gmn_freshservice_ticket_stage_log_v01.py` dan wizard `gmn_freshservice_ticket_bottleneck_wizard_v01.py`. +4. **Daftar Baris GAP pada Traceability Matrix**: + - GAP-01: Belum ada test otomatis khusus UI/JS web client saat tombol 'Tarik Tiket Terbaru' ditekan (ditutup via pengujian fungsional UAT manual). +5. **Konfirmasi Privasi**: + - Seluruh data contoh, transkrip pesan, dan cuplikan pengujian telah **dianonimkan 100%**. Tidak ada alamat email riil, nomor telepon, link rapat Zoom aktif, maupun passcode rapat yang disertakan dalam dokumentasi ini. diff --git a/gmn_freshservice/__init__.py b/gmn_freshservice/__init__.py new file mode 100644 index 0000000..f83ecc5 --- /dev/null +++ b/gmn_freshservice/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Modul utama GMN Freshservice. +Import semua sub-package: models, services, dan wizard. +""" +from . import models +from . import services +from . import wizard diff --git a/gmn_freshservice/__manifest__.py b/gmn_freshservice/__manifest__.py new file mode 100644 index 0000000..e26bfd1 --- /dev/null +++ b/gmn_freshservice/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'GMN Freshservice Ticket Audit Trail & Time Metrics', + # 'version': '18.0.1.0.0', + 'category': 'Services/Helpdesk', + 'summary': 'Pelacakan Durasi Tiket (Waiting on Us vs Waiting on User) & Audit Trail Terintegrasi Freshservice API', + 'description': """ +Freshservice Ticket Audit Trail & Time Tracking (Odoo 19) +========================================================= +Modul untuk melacak audit trail tiket dan memisahkan durasi penanganan: +* First Response Time & Pelaku Responder Pertama +* Durasi Penanganan di Sisi Internal (Waiting on Us / Agent Time) +* Durasi Penanganan di Sisi User (Waiting on Customer / User Time) +* Dilengkapi Mock Data Freshservice API v2 sesuai dokumentasi resmi +* Siap switch ke Live Freshservice API kapan saja tanpa mengubah arsitektur data. + """, + 'author': 'IT Development Team', + 'depends': [ + 'base', + 'mail', + ], + 'data': [ + 'security/gmn_freshservice_security_v01.xml', + 'security/ir.model.access.csv', + 'data/gmn_demo_data_v01.xml', + 'views/gmn_res_config_settings_views_v01.xml', + 'views/gmn_freshservice_ticket_stage_log_views_v01.xml', + 'views/gmn_freshservice_holiday_views_v01.xml', + 'wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml', + 'wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml', + 'views/gmn_freshservice_ticket_views_v01.xml', + 'views/gmn_menu_views_v01.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} + diff --git a/gmn_freshservice/__pycache__/__init__.cpython-312.pyc b/gmn_freshservice/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..518bc7d Binary files /dev/null and b/gmn_freshservice/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..2bab524 Binary files /dev/null and b/gmn_freshservice/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc b/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc new file mode 100644 index 0000000..2839325 Binary files /dev/null and b/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc differ diff --git a/gmn_freshservice/data/gmn_demo_data_v01.xml b/gmn_freshservice/data/gmn_demo_data_v01.xml new file mode 100644 index 0000000..f71eff5 --- /dev/null +++ b/gmn_freshservice/data/gmn_demo_data_v01.xml @@ -0,0 +1,123 @@ + + + + + + Kendala Koneksi Jaringan & SAP Client - Dept Finance + 129 + Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek. + resolved + high + Ahmad Dani (User) + ahmad.dani@lyman.co.id + Budi Santoso (IT Support) + Finance & Accounting + 2026-10-10 08:30:00 + 2026-10-12 17:00:00 + 2026-10-10 08:52:00 + Budi Santoso (IT Support) + 2026-10-11 16:30:00 + 1 + + + + + + Open + internal + Ahmad Dani (User) + Requester + Tiket dibuat via Web Portal dengan status Open + 2026-10-10 08:30:00 + 2026-10-10 08:52:00 + + + + + + In Progress + internal + Budi Santoso (IT Support) + Agent + First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan. + 2026-10-10 08:52:00 + 2026-10-10 09:30:00 + + + + + + Pending User + customer + Budi Santoso (IT Support) + Agent + Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap. + 2026-10-10 09:30:00 + 2026-10-11 14:00:00 + + + + + + In Progress + internal + Ahmad Dani (User) + Requester + User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning. + 2026-10-11 14:00:00 + 2026-10-11 16:30:00 + + + + + + Resolved + internal + Budi Santoso (IT Support) + Agent + Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal. + 2026-10-11 16:30:00 + 2026-10-11 16:30:00 + + + + + + 501 + Budi Santoso (IT Support) + 0 + Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3. + 2026-10-10 08:52:00 + + + + + + 502 + Budi Santoso (IT Support) + 0 + Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye? + 2026-10-10 09:30:00 + + + + + + 503 + Ahmad Dani (User) + 1 + Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus. + 2026-10-11 14:00:00 + + + + + + 504 + Budi Santoso (IT Support) + 0 + Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali. + 2026-10-11 16:30:00 + + + diff --git a/gmn_freshservice/models/__init__.py b/gmn_freshservice/models/__init__.py new file mode 100644 index 0000000..15c54ad --- /dev/null +++ b/gmn_freshservice/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""Package models: semua model Odoo untuk modul Freshservice. +Urutan import penting karena ticket bergantung pada stage_log dan conversation. +""" +from . import gmn_res_config_settings_v01 +from . import gmn_freshservice_holiday_v01 +from . import gmn_freshservice_ticket_stage_log_v01 +from . import gmn_freshservice_ticket_conversation_v01 +from . import gmn_freshservice_ticket_v01 diff --git a/gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc b/gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..209a667 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..3f2128f Binary files /dev/null and b/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc new file mode 100644 index 0000000..ef394a4 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc new file mode 100644 index 0000000..86c604f Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc new file mode 100644 index 0000000..5d4dc48 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc new file mode 100644 index 0000000..816fe56 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc new file mode 100644 index 0000000..1bbc495 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc new file mode 100644 index 0000000..692040b Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_holiday_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_holiday_v01.cpython-312.pyc new file mode 100644 index 0000000..e1724d1 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/gmn_freshservice_holiday_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc new file mode 100644 index 0000000..938db95 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc new file mode 100644 index 0000000..58424e6 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc new file mode 100644 index 0000000..df6eaf0 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc new file mode 100644 index 0000000..f3dc9f1 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc new file mode 100644 index 0000000..253d940 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc new file mode 100644 index 0000000..dfc64c6 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc differ diff --git a/gmn_freshservice/models/gmn_freshservice_holiday_v01.py b/gmn_freshservice/models/gmn_freshservice_holiday_v01.py new file mode 100644 index 0000000..541f110 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_holiday_v01.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""Model Master Data Hari Libur Nasional & Cuti Bersama. +Menyimpan daftar tanggal merah resmi Indonesia dan cuti bersama perusahaan +yang digunakan oleh Working Hours Engine untuk memotong jam kerja secara presisi. + +Mendukung: +1. Input manual melalui antarmuka Tree/Form Odoo. +2. Impor massal dari file spreadsheet Excel (.xlsx). +3. Pengunduhan template spreadsheet Excel (.xlsx) dengan contoh data. +4. Pemuatan instan data baku hari libur nasional Indonesia (2024-2026). +""" +import io +import base64 +import logging +from datetime import datetime, date +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import xlsxwriter + +_logger = logging.getLogger(__name__) + +# Daftar Baku Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026) +DEFAULT_INDONESIA_HOLIDAYS = [ + # 2024 + ('2024-01-01', 'Tahun Baru 2024 Masehi', 'public', 'SKB 3 Menteri 2024'), + ('2024-02-08', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'), + ('2024-02-09', 'Cuti Bersama Tahun Baru Imlek 2575 Kongzili', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-02-10', 'Tahun Baru Imlek 2575 Kongzili', 'public', 'SKB 3 Menteri 2024'), + ('2024-03-11', 'Hari Suci Nyepi Tahun Baru Saka 1946', 'public', 'SKB 3 Menteri 2024'), + ('2024-03-12', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-03-29', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2024'), + ('2024-03-31', 'Hari Paskah', 'public', 'SKB 3 Menteri 2024'), + ('2024-04-08', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-04-09', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-04-10', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'), + ('2024-04-11', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'), + ('2024-04-12', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-04-15', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2024'), + ('2024-05-09', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2024'), + ('2024-05-10', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-05-23', 'Hari Raya Waisak 2568 BE', 'public', 'SKB 3 Menteri 2024'), + ('2024-05-24', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2024'), + ('2024-06-17', 'Hari Raya Idul Adha 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'), + ('2024-06-18', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2024'), + ('2024-07-07', 'Tahun Baru Islam 1446 Hijriah', 'public', 'SKB 3 Menteri 2024'), + ('2024-08-17', 'Hari Proklamasi Kemerdekaan RI ke-79', 'public', 'SKB 3 Menteri 2024'), + ('2024-09-16', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'), + ('2024-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2024'), + ('2024-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2024'), + # 2025 + ('2025-01-01', 'Tahun Baru 2025 Masehi', 'public', 'SKB 3 Menteri 2025'), + ('2025-01-27', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'), + ('2025-01-28', 'Cuti Bersama Tahun Baru Imlek 2576 Kongzili', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-01-29', 'Tahun Baru Imlek 2576 Kongzili', 'public', 'SKB 3 Menteri 2025'), + ('2025-03-28', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-03-29', 'Hari Suci Nyepi Tahun Baru Saka 1947', 'public', 'SKB 3 Menteri 2025'), + ('2025-03-31', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'), + ('2025-04-01', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'), + ('2025-04-02', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-04-03', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-04-04', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-04-07', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-04-18', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2025'), + ('2025-04-20', 'Hari Paskah', 'public', 'SKB 3 Menteri 2025'), + ('2025-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2025'), + ('2025-05-12', 'Hari Raya Waisak 2569 BE', 'public', 'SKB 3 Menteri 2025'), + ('2025-05-13', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-05-29', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2025'), + ('2025-05-30', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2025'), + ('2025-06-06', 'Hari Raya Idul Adha 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'), + ('2025-06-09', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2025'), + ('2025-06-27', '1 Muharam Tahun Baru Islam 1447 H', 'public', 'SKB 3 Menteri 2025'), + ('2025-08-17', 'Hari Proklamasi Kemerdekaan RI ke-80', 'public', 'SKB 3 Menteri 2025'), + ('2025-09-05', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'), + ('2025-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2025'), + ('2025-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2025'), + # 2026 + ('2026-01-01', 'Tahun Baru 2026 Masehi', 'public', 'SKB 3 Menteri 2026'), + ('2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'), + ('2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'public', 'SKB 3 Menteri 2026'), + ('2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'), + ('2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'), + ('2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'public', 'SKB 3 Menteri 2026'), + ('2026-04-03', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2026'), + ('2026-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2026'), + ('2026-05-14', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2026'), + ('2026-05-31', 'Hari Raya Waisak 2570 BE', 'public', 'SKB 3 Menteri 2026'), + ('2026-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2026'), + ('2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'), + ('2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'public', 'SKB 3 Menteri 2026'), + ('2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'public', 'SKB 3 Menteri 2026'), + ('2026-08-25', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'), + ('2026-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2026'), +] + + +class FreshserviceHoliday(models.Model): + """Model Master Data Hari Libur Nasional & Cuti Bersama. + Menyimpan tanggal merah yang dijadikan acuan pemotongan jam kerja oleh modul. + """ + _name = 'freshservice.holiday' + _description = 'Master Data Hari Libur & Cuti Bersama Indonesia' + _order = 'date desc, id desc' + + name = fields.Char(string='Nama Hari Libur / Keterangan', required=True, index=True) + date = fields.Date(string='Tanggal Libur', required=True, index=True) + holiday_type = fields.Selection([ + ('public', 'Hari Libur Nasional'), + ('joint_leave', 'Cuti Bersama'), + ('company', 'Libur Khusus Perusahaan'), + ], string='Jenis Libur', default='public', required=True) + year = fields.Integer(string='Tahun', compute='_compute_year', store=True, index=True) + active = fields.Boolean(string='Aktif', default=True) + notes = fields.Text(string='Catatan Tambahan') + + _sql_constraints = [ + ('date_unique', 'unique(date)', 'Tanggal hari libur ini sudah terdaftar dalam sistem!') + ] + + @api.depends('date') + def _compute_year(self): + """Menghitung tahun secara otomatis dari tanggal libur.""" + for rec in self: + rec.year = rec.date.year if rec.date else 0 + + def action_populate_default_holidays(self, *args, **kwargs): + """Memuat daftar hari libur baku Indonesia (2024-2026) ke dalam database. + Hanya menambahkan data yang belum terdaftar tanpa menimpa data yang sudah ada. + """ + created_count = 0 + for date_str, name, h_type, notes in DEFAULT_INDONESIA_HOLIDAYS: + existing = self.search([('date', '=', date_str)], limit=1) + if not existing: + self.create({ + 'date': date_str, + 'name': name, + 'holiday_type': h_type, + 'notes': notes, + 'active': True, + }) + created_count += 1 + + message = _('Berhasil memuat %d hari libur nasional baku ke database.') % created_count if created_count > 0 else _('Seluruh hari libur nasional baku sudah terdaftar.') + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Pemuatan Data Baku Selesai'), + 'message': message, + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.client', 'tag': 'reload'}, + } + } + + def action_download_template(self, *args, **kwargs): + """Membuat dan mengunduh file template Excel (.xlsx) untuk impor hari libur. + Template dilengkapi dengan format kolom yang benar dan contoh data resmi 2026. + """ + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + worksheet = workbook.add_worksheet('Template Hari Libur') + + # Formatter Styling Excel + header_banner_fmt = workbook.add_format({ + 'bold': True, 'font_size': 14, 'font_name': 'Calibri', + 'font_color': '#FFFFFF', 'bg_color': '#1B365D', + 'align': 'left', 'valign': 'vcenter' + }) + info_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'font_color': '#5D6D7E', + 'italic': True, 'valign': 'vcenter' + }) + th_fmt = workbook.add_format({ + 'bold': True, 'font_size': 10, 'font_name': 'Calibri', + 'font_color': '#FFFFFF', 'bg_color': '#2E4053', + 'align': 'center', 'valign': 'vcenter', 'border': 1, 'border_color': '#BDC3C7' + }) + td_center_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'center', + 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_date_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'center', + 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter', + 'num_format': 'yyyy-mm-dd' + }) + td_left_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'left', + 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + + # Set Lebar Kolom + worksheet.set_column('A:A', 6) + worksheet.set_column('B:B', 16) + worksheet.set_column('C:C', 40) + worksheet.set_column('D:D', 26) + worksheet.set_column('E:E', 35) + + # Header Title Banner + worksheet.merge_range('A1:E1', 'TEMPLATE IMPORT HARI LIBUR NASIONAL & CUTI BERSAMA', header_banner_fmt) + worksheet.set_row(0, 30) + + # Petunjuk Pengisian + worksheet.write('A2', 'PETUNJUK PENGISIAN:', info_fmt) + worksheet.write('A3', '1. Kolom Tanggal Libur (*) dan Nama Hari Libur (*) wajib diisi. Format tanggal: YYYY-MM-DD (contoh: 2026-08-17).', info_fmt) + worksheet.write('A4', '2. Kolom Jenis Libur dapat diisi: "Hari Libur Nasional", "Cuti Bersama", atau "Libur Khusus Perusahaan".', info_fmt) + worksheet.write('A5', '3. Anda dapat mengedit, menambah, atau menghapus baris contoh di bawah ini sebelum mengimpor.', info_fmt) + + # Table Headers (Baris 7) + headers = ['No', 'Tanggal Libur (*)', 'Nama Hari Libur (*)', 'Jenis Libur', 'Keterangan'] + for col_idx, h in enumerate(headers): + worksheet.write(6, col_idx, h, th_fmt) + worksheet.set_row(6, 24) + + # Contoh Data (Hari Libur Resmi 2026 sebagai sampel lengkap) + sample_data = [ + (1, '2026-01-01', 'Tahun Baru 2026 Masehi', 'Hari Libur Nasional', 'Libur Nasional Resmi'), + (2, '2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (3, '2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (4, '2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (5, '2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (6, '2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (7, '2026-04-03', 'Wafat Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (8, '2026-05-01', 'Hari Buruh Internasional', 'Hari Libur Nasional', 'Hari Buruh'), + (9, '2026-05-14', 'Kenaikan Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (10, '2026-05-31', 'Hari Raya Waisak 2570 BE', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (11, '2026-06-01', 'Hari Lahir Pancasila', 'Hari Libur Nasional', 'Hari Lahir Pancasila'), + (12, '2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (13, '2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (14, '2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'Hari Libur Nasional', 'Proklamasi Kemerdekaan'), + (15, '2026-08-25', 'Maulid Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'), + (16, '2026-12-25', 'Hari Raya Natal', 'Hari Libur Nasional', 'Hari Natal'), + ] + + row = 7 + for no, dt_str, name, h_type, notes in sample_data: + worksheet.write(row, 0, no, td_center_fmt) + worksheet.write(row, 1, dt_str, td_date_fmt) + worksheet.write(row, 2, name, td_left_fmt) + worksheet.write(row, 3, h_type, td_left_fmt) + worksheet.write(row, 4, notes, td_left_fmt) + row += 1 + + workbook.close() + output.seek(0) + file_data = output.read() + output.close() + + filename = 'Template_Hari_Libur_Indonesia.xlsx' + attachment = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'datas': base64.b64encode(file_data), + 'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'public': True, + }) + + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{attachment.id}?download=true', + 'target': 'new', + } + + def action_open_import_wizard(self, *args, **kwargs): + """Membuka dialog popup wizard impor hari libur dari file Excel.""" + return { + 'name': _('Impor Hari Libur dari Excel'), + 'type': 'ir.actions.act_window', + 'res_model': 'freshservice.holiday.import.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': dict(self.env.context), + } diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py new file mode 100644 index 0000000..c12ee77 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice. +Setiap baris adalah satu pesan, baik dari user maupun dari agen IT. +""" +from odoo import models, fields + + +class FreshserviceTicketConversation(models.Model): + """Menyimpan pesan/balasan pada tiket. + Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel. + """ + _name = 'freshservice.ticket.conversation' + _description = 'Percakapan & Balasan Tiket Freshservice' + _order = 'created_at asc, id asc' + + ticket_id = fields.Many2one( + 'freshservice.ticket', + string='Tiket', + required=True, + ondelete='cascade', + index=True + ) + + external_id = fields.Char(string='ID Percakapan') + user_name = fields.Char(string='Pengirim') + user_id_external = fields.Char(string='User ID Freshservice') + + incoming = fields.Boolean( + string='Pesan Masuk (Dari User)', + help='True jika dikirim oleh Customer/Requester, False jika balasan dari Agen IT.' + ) + + sender_type = fields.Selection([ + ('agent', 'IT Support (Agent)'), + ('customer', 'User (Requester)'), + ], string='Tipe Pengirim', compute='_compute_sender_type', store=True) + + body_text = fields.Text(string='Isi Pesan') + created_at = fields.Datetime(string='Waktu Kirim') + + def _compute_sender_type(self): + """Tentukan siapa yang kirim pesan: + - 'customer' jika incoming=True (pesan dari user) + - 'agent' jika incoming=False (balasan dari agen IT) + """ + for rec in self: + rec.sender_type = 'customer' if rec.incoming else 'agent' diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py new file mode 100644 index 0000000..31b44d9 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py @@ -0,0 +1,430 @@ +# -*- coding: utf-8 -*- +"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi. +Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan. +""" +from datetime import timedelta, datetime, time +from odoo import models, fields, api + +# ============================================================ +# KONSTANTA JAM KERJA OPERASIONAL (WIB / UTC+7) +# Senin-Jumat, 08:30-17:30, Break 12:30-13:30 (8 jam efektif) +# ============================================================ +_WORK_START = time(8, 30) # 08:30 +_WORK_END = time(17, 30) # 17:30 +_BREAK_START = time(12, 30) # 12:30 +_BREAK_END = time(13, 30) # 13:30 +_WORK_HOURS_PER_DAY = 8.0 # jam efektif per hari + + +def _working_seconds_in_day(t_start, t_end): + """Hitung detik kerja efektif dalam satu hari antara t_start dan t_end (time objects). + Mengecualikan break 12:30-13:30 dan di luar jam kerja 08:30-17:30. + """ + # Clamp ke jam kerja + t_start = max(t_start, _WORK_START) + t_end = min(t_end, _WORK_END) + if t_end <= t_start: + return 0.0 + + def to_sec(t): + return t.hour * 3600 + t.minute * 60 + t.second + + total = to_sec(t_end) - to_sec(t_start) + + # Kurangi overlap dengan break 12:30-13:30 + break_s = max(t_start, _BREAK_START) + break_e = min(t_end, _BREAK_END) + if break_e > break_s: + total -= (to_sec(break_e) - to_sec(break_s)) + + return max(0.0, total) + + +# Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026) +_INDONESIA_HOLIDAYS = { + # 2024 + datetime(2024, 1, 1).date(), datetime(2024, 2, 8).date(), datetime(2024, 2, 9).date(), + datetime(2024, 2, 10).date(), datetime(2024, 3, 11).date(), datetime(2024, 3, 12).date(), + datetime(2024, 3, 29).date(), datetime(2024, 3, 31).date(), datetime(2024, 4, 8).date(), + datetime(2024, 4, 9).date(), datetime(2024, 4, 10).date(), datetime(2024, 4, 11).date(), + datetime(2024, 4, 12).date(), datetime(2024, 4, 15).date(), datetime(2024, 5, 1).date(), + datetime(2024, 5, 9).date(), datetime(2024, 5, 10).date(), datetime(2024, 5, 23).date(), + datetime(2024, 5, 24).date(), datetime(2024, 6, 1).date(), datetime(2024, 6, 17).date(), + datetime(2024, 6, 18).date(), datetime(2024, 7, 7).date(), datetime(2024, 8, 17).date(), + datetime(2024, 9, 16).date(), datetime(2024, 12, 25).date(), datetime(2024, 12, 26).date(), + # 2025 + datetime(2025, 1, 1).date(), datetime(2025, 1, 27).date(), datetime(2025, 1, 28).date(), + datetime(2025, 1, 29).date(), datetime(2025, 3, 28).date(), datetime(2025, 3, 29).date(), + datetime(2025, 3, 31).date(), datetime(2025, 4, 1).date(), datetime(2025, 4, 2).date(), + datetime(2025, 4, 3).date(), datetime(2025, 4, 4).date(), datetime(2025, 4, 7).date(), + datetime(2025, 4, 18).date(), datetime(2025, 4, 20).date(), datetime(2025, 5, 1).date(), + datetime(2025, 5, 12).date(), datetime(2025, 5, 13).date(), datetime(2025, 5, 29).date(), + datetime(2025, 5, 30).date(), datetime(2025, 6, 1).date(), datetime(2025, 6, 6).date(), + datetime(2025, 6, 9).date(), datetime(2025, 6, 27).date(), datetime(2025, 8, 17).date(), + datetime(2025, 9, 5).date(), datetime(2025, 12, 25).date(), datetime(2025, 12, 26).date(), + # 2026 + datetime(2026, 1, 1).date(), datetime(2026, 1, 16).date(), datetime(2026, 2, 17).date(), + datetime(2026, 3, 19).date(), datetime(2026, 3, 20).date(), datetime(2026, 3, 21).date(), + datetime(2026, 4, 3).date(), datetime(2026, 5, 1).date(), datetime(2026, 5, 14).date(), + datetime(2026, 5, 31).date(), datetime(2026, 6, 1).date(), datetime(2026, 6, 27).date(), + datetime(2026, 7, 16).date(), datetime(2026, 8, 17).date(), datetime(2026, 8, 25).date(), + datetime(2026, 12, 25).date(), +} + + +def _holiday_dates(env, start_date, end_date): + """Return public leave dates combining Indonesian public holidays with Odoo's calendar leaves. + + Prioritas Pengambilan Data: + 1. Model Master Data `freshservice.holiday` di database (hasil input manual atau impor Excel). + 2. Fallback ke konstanta `_INDONESIA_HOLIDAYS` jika tabel master data di DB masih kosong + atau jika env Odoo tidak tersedia (misal unit test standalone). + 3. Menggabungkan dengan cuti dinamis dari `resource.calendar.leaves` jika tersedia. + """ + res = set() + used_db = False + + if env and hasattr(env, '__getitem__'): + try: + HolidayModel = env['freshservice.holiday'].sudo() + # Cek apakah ada record hari libur aktif di database + db_records = HolidayModel.search([ + ('date', '>=', start_date), + ('date', '<=', end_date), + ('active', '=', True), + ]) + if db_records: + res.update(rec.date for rec in db_records) + used_db = True + elif HolidayModel.search_count([('active', '=', True)]) > 0: + # Database sudah memiliki data libur, namun tidak ada libur di rentang tanggal ini + used_db = True + except Exception: + pass + + if not used_db: + # Fallback ke konstanta _INDONESIA_HOLIDAYS jika DB belum terisi / tanpa koneksi DB + res.update(d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date) + + if env and hasattr(env, '__getitem__'): + try: + leaves = env['resource.calendar.leaves'].sudo().search([ + ('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)), + ('date_to', '>=', datetime.combine(start_date, time.min)), + ]) + for leave in leaves: + if leave.date_from: + res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date()) + except Exception: + pass + + return res + + +def compute_working_hours(env, start_dt, end_dt=None): + """Menghitung jam kerja efektif antara start_dt dan end_dt. + + Aturan: + - Jam kerja: 08:30 - 17:30 WIB (UTC+7) + - Break: 12:30 - 13:30 (tidak dihitung) + - Hari kerja: Senin - Jumat (Sabtu & Minggu dilewati) + - Jam di DB diasumsikan UTC → dikonversi ke WIB dengan +7 jam + + Returns: + float: total jam kerja efektif (dibulatkan 2 desimal) + """ + if not start_dt: + return 0.0 + + # Normalisasi ke datetime naive (UTC) + def to_naive_utc(dt): + if isinstance(dt, str): + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f'): + try: + return datetime.strptime(dt, fmt) + except ValueError: + continue + return None + if hasattr(dt, 'tzinfo') and dt.tzinfo is not None: + return dt.replace(tzinfo=None) - dt.utcoffset() + return dt + + s_utc = to_naive_utc(start_dt) + e_utc = to_naive_utc(end_dt) if end_dt else datetime.utcnow() + + if not s_utc or not e_utc or e_utc <= s_utc: + return 0.0 + + # Konversi ke WIB (UTC+7) + s_wib = s_utc + timedelta(hours=7) + e_wib = e_utc + timedelta(hours=7) + + total_seconds = 0.0 + curr = s_wib.date() + end_date = e_wib.date() + holidays = _holiday_dates(env, curr, end_date) + + while curr <= end_date: + # Lewati Sabtu, Minggu, dan tanggal leave/libur nasional terdaftar + if curr.weekday() >= 5 or curr in holidays: + curr += timedelta(days=1) + continue + + # Tentukan window waktu untuk hari ini + day_start = datetime.combine(curr, _WORK_START) + day_end = datetime.combine(curr, _WORK_END) + + # Clamp ke range s_wib - e_wib + seg_start = max(datetime.combine(curr, time(0, 0)), s_wib) + seg_end = min(datetime.combine(curr, time(23, 59, 59)), e_wib) + + # Ambil time portion untuk _working_seconds_in_day + t_s = max(seg_start, day_start) + t_e = min(seg_end, day_end) + + if t_e > t_s: + total_seconds += _working_seconds_in_day(t_s.time(), t_e.time()) + + curr += timedelta(days=1) + + return round(total_seconds / 3600.0, 2) + + +def format_working_duration(hours_float, is_active=False): + """Format jam kerja efektif menjadi string yang mudah dibaca. + Contoh: 12.0 → '12 Jam (1.5 Hari Kerja)' + """ + if not hours_float or hours_float <= 0: + suffix = ' (Aktif)' if is_active else '' + return f'0 Jam{suffix}' + h = round(hours_float, 2) + wd = round(h / _WORK_HOURS_PER_DAY, 2) + # Tampilkan hari kerja: bulatkan ke .5 terdekat untuk keterbacaan + wd_display = round(wd * 2) / 2 + suffix = ' (Aktif)' if is_active else '' + return f'{h:.4g} Jam ({wd_display:.4g} Hari Kerja){suffix}' + + +def compute_calendar_days(env, start_dt, end_dt=None, exclude_weekends=True): + """ + Menghitung selisih hari kerja (Working Days / Business Days) berdasarkan pergantian tanggal. + Secara default mengecualikan hari Sabtu (weekday=5) dan Minggu (weekday=6), + sehingga hari libur akhir pekan tidak terhitung sebagai waiting time / penalti SLA. + + - exclude_weekends=True (default): + * Dibuat Jumat, selesai Senin = 1 Hari Kerja (Sabtu & Minggu diskip). + * Dibuat Jumat, selesai Jumat = 0 Hari Kerja. + * Dibuat Sabtu, selesai Minggu = 0 Hari Kerja. + - exclude_weekends=False: + * Menghitung total hari kalender biasa murni. + """ + if not start_dt: + return 0 + try: + user_tz = env.context.get('tz') or env.user.tz or 'Asia/Jakarta' + user_record = env.user.with_context(tz=user_tz) + start_date = fields.Datetime.context_timestamp(user_record, start_dt).date() + if end_dt: + end_date = fields.Datetime.context_timestamp(user_record, end_dt).date() + else: + now_dt = fields.Datetime.now() + end_date = fields.Datetime.context_timestamp(user_record, now_dt).date() + + if end_date <= start_date: + return 0 + + if not exclude_weekends: + return (end_date - start_date).days + + # Hitung hari kerja dengan melewati Sabtu (5) dan Minggu (6) + work_days = 0 + curr = start_date + timedelta(days=1) + while curr <= end_date: + if curr.weekday() < 5: # 0=Senin ... 4=Jumat + work_days += 1 + curr += timedelta(days=1) + return work_days + except Exception: + s_date = start_dt.date() if hasattr(start_dt, 'date') else start_dt + e_date = (end_dt or fields.Datetime.now()).date() if hasattr(end_dt or fields.Datetime.now(), 'date') else end_dt + if e_date <= s_date: + return 0 + if not exclude_weekends: + return max(0, (e_date - s_date).days) + work_days = 0 + curr = s_date + timedelta(days=1) + while curr <= e_date: + if curr.weekday() < 5: + work_days += 1 + curr += timedelta(days=1) + return work_days + +def format_duration_days(days_int, is_active=False): + """Memformat durasi dalam bilangan bulat Hari.""" + days = max(0, int(days_int or 0)) + if is_active: + return f"{days} Hari (Aktif)" + return f"{days} Hari" + +def format_duration_human(hours_float): + """Helper kompatibilitas yang sekarang menampilkan satuan hari.""" + if not hours_float or hours_float <= 0: + return "0 Hari" + days = int(round(hours_float / 24.0)) + return f"{days} Hari" + +class FreshserviceTicketStageLog(models.Model): + """Satu baris = satu fase/status di tiket. + Contoh: Open → In Progress → Pending User → Resolved. + Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya, + dan siapa yang bertanggung jawab (Agent/User/Third Party). + """ + _name = 'freshservice.ticket.stage.log' + _description = 'Audit Trail Riwayat Status & Durasi Tiket' + _order = 'start_date asc, id asc' + + ticket_id = fields.Many2one( + 'freshservice.ticket', + string='Tiket', + required=True, + ondelete='cascade', + index=True + ) + + stage_name = fields.Char(string='Status / Stage', required=True) + + responsibility = fields.Selection([ + ('internal', 'Agent Active Work'), + ('it_hold', 'Agent Hold & Discussion'), + ('customer', 'Waiting on Requester'), + ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'), + ('needs_verification', 'Waiting on User Verification'), + ('scheduled_meeting', 'Scheduled Meeting (Shared)'), + ('third_party', 'Waiting on Third Party'), + ('resolved_idle', 'Resolved / Idle'), + # Legacy values are retained for historical audit snapshots. + ('on_hold', 'On Hold (Legacy)'), + ('internal_discussion', 'Internal Discussion (Legacy)'), + ('external_activity', 'External Activity (Legacy)'), + ], string='Task Owner', required=True, default='internal', index=True) + + effective_status = fields.Char(string='Effective Status', index=True) + classification_basis = fields.Char(string='Classification Basis') + needs_verification = fields.Boolean(string='User Verification Required') + delay_cause = fields.Selection([ + ('it', 'Agent'), ('user', 'User'), ('none', 'None'), + ], string='Delay Cause', default='none') + automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True) + override_responsibility = fields.Selection([ + ('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'), + ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'), + ('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'), + ('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'), + ], string='Task Owner Override') + override_source = fields.Char(string='Override Source') + override_reviewer = fields.Char(string='Override Reviewer') + override_at = fields.Datetime(string='Override Date') + + actor_name = fields.Char(string='Pelaku / Actor') + actor_type = fields.Selection([ + ('Agent', 'IT Support / Agent'), + ('Requester', 'User / Customer'), + ('System', 'System'), + ], string='Tipe Pelaku', default='Agent') + + activity_content = fields.Text(string='Deskripsi Aktivitas') + + start_date = fields.Datetime(string='Waktu Mulai', required=True) + end_date = fields.Datetime(string='Waktu Selesai') + + is_active = fields.Boolean( + string='Sedang Berjalan', + compute='_compute_is_active', + store=True + ) + + duration_days = fields.Integer( + string='Durasi (Hari)', + compute='_compute_duration', + store=True + ) + + duration_hours = fields.Float( + string='Durasi (Jam)', + compute='_compute_duration', + store=True, + digits=(12, 2) + ) + + duration_display = fields.Char( + string='Durasi', + compute='_compute_duration', + store=True + ) + + working_hours = fields.Float( + string='Jam Kerja', + compute='_compute_working_metrics', + store=True, + digits=(12, 2) + ) + + working_days_effective = fields.Float( + string='Hari Kerja Efektif', + compute='_compute_working_metrics', + store=True, + digits=(12, 2) + ) + + working_duration_display = fields.Char( + string='Durasi Jam Kerja', + compute='_compute_working_metrics', + store=True + ) + + @api.depends('end_date') + def _compute_is_active(self): + """Stage dianggap masih aktif kalau belum ada end_date (belum selesai).""" + for rec in self: + rec.is_active = not bool(rec.end_date) + + @api.depends('start_date', 'end_date', 'is_active') + def _compute_working_metrics(self): + """Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat) + dan konversi ke hari kerja (1 hari = 8 jam). + """ + for rec in self: + if rec.start_date: + wh = compute_working_hours(self.env, rec.start_date, rec.end_date) + rec.working_hours = wh + rec.working_days_effective = round(wh / 8.0, 2) + rec.working_duration_display = format_working_duration(wh, is_active=rec.is_active) + else: + rec.working_hours = 0.0 + rec.working_days_effective = 0.0 + rec.working_duration_display = "-" + + @api.depends('start_date', 'end_date') + def _compute_duration(self): + """Hitung durasi dalam hari kalender dan jam total. + Kalau stage masih aktif, hitung sampai waktu sekarang. + """ + for rec in self: + if rec.start_date: + days = compute_calendar_days(self.env, rec.start_date, rec.end_date) + rec.duration_days = days + if rec.end_date: + delta = rec.end_date - rec.start_date + hours = max(0.0, delta.total_seconds() / 3600.0) + rec.duration_hours = round(hours, 2) + rec.duration_display = format_duration_days(days) + else: + # Sedang aktif berjalan sampai sekarang + delta = fields.Datetime.now() - rec.start_date + hours = max(0.0, delta.total_seconds() / 3600.0) + rec.duration_hours = round(hours, 2) + rec.duration_display = format_duration_days(days, is_active=True) + else: + rec.duration_days = 0 + rec.duration_hours = 0.0 + rec.duration_display = "-" diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py new file mode 100644 index 0000000..3b9b525 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py @@ -0,0 +1,1131 @@ +# -*- coding: utf-8 -*- +"""Model utama Freshservice Ticket. +Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API. +""" +import re +import logging +from datetime import datetime, timedelta, timezone +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from ..services.gmn_freshservice_client_v01 import FreshserviceClient +from .gmn_freshservice_ticket_stage_log_v01 import format_duration_human, format_duration_days, compute_calendar_days + +_logger = logging.getLogger(__name__) + +class FreshserviceTicket(models.Model): + """Model tiket Freshservice di Odoo. + Menyimpan info tiket (judul, status, prioritas, pemohon, agen), + metrik SLA (first response, resolution time, breakdown per tanggung jawab), + dan relasi ke stage log (audit trail) serta percakapan. + """ + _name = 'freshservice.ticket' + _description = 'Freshservice Ticket with Audit Trail' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'ticket_created_at desc, id desc' + + name = fields.Char(string='Subject / Judul Tiket', required=True, tracking=True) + external_ticket_id = fields.Char(string='ID Tiket Freshservice', required=True, index=True, default='129') + + description = fields.Text(string='Deskripsi Tiket') + + status = fields.Selection([ + ('open', 'Open'), + ('pending', 'Pending User'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ], string='Status', default='open', tracking=True, index=True) + + priority = fields.Selection([ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High'), + ('urgent', 'Urgent'), + ], string='Prioritas', default='medium', tracking=True) + + requester_name = fields.Char(string='Pemohon (User)', tracking=True) + requester_email = fields.Char(string='Email Pemohon') + responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True) + department_name = fields.Char(string='Departemen') + + # ========================================== + # USER & PENUGASAN (Dari Freshservice) + # ========================================== + user_id = fields.Many2one( + 'res.users', + string='Petugas Odoo', + tracking=True, + index=True, + help='User internal Odoo yang bertanggung jawab menangani tiket ini.' + ) + user_ids = fields.Many2many( + 'res.users', + 'freshservice_ticket_user_rel', + 'ticket_id', + 'user_id', + string='Tim Penangan', + tracking=True, + help='Daftar user internal yang turut berkolaborasi dalam tiket ini.' + ) + + ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True) + due_by = fields.Datetime(string='Batas Waktu (Due Date)') + first_responded_at = fields.Datetime(string='Waktu First Response') + first_ack_at = fields.Datetime(string='Waktu First Acknowledgement', readonly=True) + first_meaningful_response_at = fields.Datetime( + string='Waktu First Meaningful Response', readonly=True, + ) + resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)') + + is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True) + is_detail_synced = fields.Boolean( + string='Detail Lengkap Tersinkron', + default=False, + readonly=True, + copy=False, + help='Menandakan apakah detail riwayat status & percakapan tiket sudah di-fetch dari Freshservice.' + ) + last_detail_sync = fields.Datetime( + string='Waktu Terakhir Sync Detail', + readonly=True, + copy=False + ) + + # ========================================== + # METRIK SLA & BREAKDOWN DURASI (HARI) + # ========================================== + first_responder_name = fields.Char(string='First Responder', tracking=True) + + first_response_time_days = fields.Integer( + string='First Response Time (Hari)', + compute='_compute_time_metrics', + store=True + ) + first_response_time_hours = fields.Float( + string='First Response Time (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + first_response_time_display = fields.Char( + string='First Response Time', + compute='_compute_time_metrics', + store=True + ) + + total_agent_time_days = fields.Integer( + string='Total Waktu di Agen (Hari)', + compute='_compute_time_metrics', + store=True + ) + total_agent_time_hours = fields.Float( + string='Total Waktu di Agen (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + total_agent_time_display = fields.Char( + string='Agent Responsibility', + compute='_compute_time_metrics', + store=True + ) + + total_customer_time_days = fields.Integer( + string='Requester Time (Days)', + compute='_compute_time_metrics', + store=True + ) + total_customer_time_hours = fields.Float( + string='Requester Time (Hours)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + total_customer_time_display = fields.Char( + string='Requester Responsibility', + compute='_compute_time_metrics', + store=True + ) + + shared_wait_time_days = fields.Integer( + string='Scheduled Meeting (Days)', + compute='_compute_time_metrics', + store=True + ) + shared_wait_time_hours = fields.Float( + string='Scheduled Meeting (Hours)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + shared_wait_time_display = fields.Char( + string='Scheduled Meeting', + compute='_compute_time_metrics', + store=True + ) + + third_party_time_days = fields.Integer( + string='Waiting on Third Party (Hari)', + compute='_compute_time_metrics', + store=True + ) + third_party_time_hours = fields.Float( + string='Waiting on Third Party (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + third_party_time_display = fields.Char( + string='Waiting on Third Party', + compute='_compute_time_metrics', + store=True + ) + + resolution_time_days = fields.Integer( + string='Waktu Penyelesaian (Hari)', + compute='_compute_time_metrics', + store=True + ) + resolution_time_hours = fields.Float( + string='Waktu Penyelesaian (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + resolution_time_display = fields.Char( + string='Total Resolution Time', + compute='_compute_time_metrics', + store=True + ) + + current_responsibility = fields.Selection([ + ('internal', 'Agent Active Work'), + ('customer', 'Waiting on Requester'), + ('closed', 'Completed'), + ], string='Current Responsibility', compute='_compute_time_metrics', store=True) + + # Relasi + stage_log_ids = fields.One2many( + 'freshservice.ticket.stage.log', + 'ticket_id', + string='Riwayat Audit Trail & Durasi' + ) + conversation_ids = fields.One2many( + 'freshservice.ticket.conversation', + 'ticket_id', + string='Daftar Percakapan & Balasan' + ) + + @api.depends( + 'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced', + 'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active' + ) + def _compute_time_metrics(self): + """Hitung semua metrik waktu tiket: + - First Response Time (berapa lama sampai agen pertama kali merespon) + - Breakdown jam kerja per tanggung jawab (Agent/User/Meeting/Third Party) + - Resolution Time (total waktu sampai tiket selesai) + - Status tanggung jawab saat ini (siapa yang harus action) + """ + for rec in self: + # 1. Hitung First Response + if rec.ticket_created_at and rec.first_responded_at: + delta_fr = rec.first_responded_at - rec.ticket_created_at + hours_fr = max(0.0, delta_fr.total_seconds() / 3600.0) + rec.first_response_time_hours = round(hours_fr, 2) + + fr_days = compute_calendar_days(self.env, rec.ticket_created_at, rec.first_responded_at) + rec.first_response_time_days = fr_days + rec.first_response_time_display = format_duration_days(fr_days) + else: + rec.first_response_time_hours = 0.0 + rec.first_response_time_days = 0 + rec.first_response_time_display = "Belum Ada Respon" + + # 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender. + # Mengikuti Aturan Fair Responsibility: + # - IT Aktif / IT Hold / On Hold / Internal Discussion: Agen (Waiting on Us) + # - User / User Setelah Auto Resolve: Customer (Waiting on User) + # - Perlu Verifikasi: Dicatat tersendiri / dapat masuk rentang estimasi + # - Scheduled Meeting: Bersama + # - Third Party: Pihak Ketiga + agent_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('internal', 'it_hold', 'on_hold', 'internal_discussion')) + cust_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('customer', 'user_after_auto_resolve', 'needs_verification')) + shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'scheduled_meeting') + third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('third_party', 'external_activity')) + agent_days = round(agent_hours / 8.0, 2) + cust_days = round(cust_hours / 8.0, 2) + shared_wait_days = round(shared_wait_hours / 8.0, 2) + third_party_days = round(third_party_hours / 8.0, 2) + + # Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT. + total_working_hours = sum(l.working_hours for l in rec.stage_log_ids) + resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'resolved_idle') + + rec.total_agent_time_hours = round(agent_hours, 2) + rec.total_agent_time_days = round(agent_days) + rec.total_agent_time_display = format_duration_days(round(agent_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + + rec.total_customer_time_hours = round(cust_hours, 2) + rec.total_customer_time_days = round(cust_days) + rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + + rec.shared_wait_time_hours = round(shared_wait_hours, 2) + rec.shared_wait_time_days = round(shared_wait_days) + rec.shared_wait_time_display = format_duration_days(round(shared_wait_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + + rec.third_party_time_hours = round(third_party_hours, 2) + rec.third_party_time_days = round(third_party_days) + rec.third_party_time_display = format_duration_days(round(third_party_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + + # 3. Hitung Resolution Time + if rec.ticket_created_at and rec.resolved_at: + delta_res = rec.resolved_at - rec.ticket_created_at + hours_res = max(0.0, delta_res.total_seconds() / 3600.0) + rec.resolution_time_hours = round(hours_res, 2) + + res_days = compute_calendar_days(self.env, rec.ticket_created_at, rec.resolved_at) + rec.resolution_time_days = res_days + rec.resolution_time_display = format_duration_days(res_days) + else: + rec.resolution_time_hours = 0.0 + rec.resolution_time_days = 0 + rec.resolution_time_display = "Masih Berjalan" + + # 4. Status Tanggung Jawab Saat Ini + if rec.status in ('resolved', 'closed'): + rec.current_responsibility = 'closed' + elif rec.status == 'pending': + rec.current_responsibility = 'customer' + else: + rec.current_responsibility = 'internal' + + def action_open_bottleneck_wizard(self): + """Membuka Pop-up Wizard Analisis Bottleneck Excel untuk tiket ini.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Laporan Analisis Bottleneck Excel'), + 'res_model': 'freshservice.ticket.bottleneck.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_report_type': 'single', + 'default_ticket_id': self.id, + 'default_external_ticket_id': self.external_ticket_id, + } + } + + def action_assign_to_me(self): + """Tombol 'Assign ke Saya': tugaskan tiket ini ke user yang sedang login. + Otomatis catat di chatter siapa yang ambil alih tiket. + """ + self.ensure_one() + current_user = self.env.user + vals = {'user_id': current_user.id} + if current_user not in self.user_ids: + vals['user_ids'] = [(4, current_user.id)] + self.write(vals) + self.message_post( + body=_("Tiket ini telah ditugaskan kepada %s.", current_user.name), + message_type='notification', + subtype_xmlid='mail.mt_note' + ) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Penugasan Berhasil'), + 'message': _('Tiket #%s telah ditugaskan kepada Anda (%s).', self.external_ticket_id, current_user.name), + 'type': 'success', + 'sticky': False, + } + } + + def _ball_in_court_check(self, effective_status, last_msg, last_actor_type): + """ + Aturan B: Ball-in-court analysis. + Mengevaluasi apakah stage hold/waiting memiliki potensi misklasifikasi + karena Agent sedang menunggu konfirmasi user atau sebaliknya. + Mengembalikan (is_needs_verification, basis_snippet). + """ + if not last_msg: + return False, "" + msg_lower = last_msg.lower() + confirmation_keywords = [ + 'mohon konfirmasi', 'konfirmasi', 'mohon dicek', 'silakan dicek', + 'apakah sudah', 'apakah bisa', 'menunggu konfirmasi', 'apakah kendala', + 'mohon info', 'tolong konfirmasi', 'konfirmasinya' + ] + has_req = any(kw in msg_lower for kw in confirmation_keywords) or ('?' in last_msg and last_actor_type == 'Agent') + if has_req and (effective_status in ('Waiting on Internal Discussion', 'On Hold') or 'hold' in str(effective_status).lower()): + words = last_msg.strip().split() + snippet = ' '.join(words[:10]) + ('...' if len(words) > 10 else '') + return True, f"Agent meminta konfirmasi: \"{snippet}\"" + return False, "" + + def action_sync_from_freshservice(self): + """Tarik data lengkap tiket dari Freshservice API. + Proses: + 1. Ambil detail tiket (subject, status, requester, dll) + 2. Ambil audit trail activities lalu rekonstruksi jadi stage log + 3. Ambil percakapan (reply/note) + Pakai row-lock supaya aman kalau 2 user sync bersamaan. + """ + self.ensure_one() + + # Concurrency safety: Row-level lock agar proses multi-user tidak bentrok + try: + self.env.cr.execute( + "SELECT id FROM freshservice_ticket WHERE id = %s FOR UPDATE NOWAIT", + [self.id], + log_exceptions=False + ) + except Exception: + _logger.info("Tiket #%s sedang diproses oleh sesi lain. Melewati sinkronisasi konkuren.", self.id) + return + + get_param = self.env['ir.config_parameter'].sudo().get_param + base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = get_param('gmn_freshservice.freshservice_api_key', '') + use_mock = False + + client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) + payload = client.fetch_ticket_full_data(self.external_ticket_id) + + ticket_data = payload.get('ticket', {}) + activities_data = payload.get('activities', []) + conversations_data = payload.get('conversations', []) + + # 1. Update Header Tiket + stats = ticket_data.get('stats', {}) + status_code = ticket_data.get('status') + status_val = FreshserviceClient.STATUS_MAP.get(status_code, 'open') + priority_code = ticket_data.get('priority') + priority_val = FreshserviceClient.PRIORITY_MAP.get(priority_code, 'medium') + + req_obj = ticket_data.get('requester') or {} + requester_name = req_obj.get('name') or ticket_data.get('requester_name') or '' + requester_email = req_obj.get('email') or ticket_data.get('requester_email') or ticket_data.get('email') or '' + responder_name = ticket_data.get('responder_name') or '' + department_name = ticket_data.get('department_name') or '' + + # Fallback jika responder_name kosong, cari dari actor bertipe agent di activities + if not responder_name and activities_data: + for act in activities_data: + actor = act.get('actor') or {} + if actor.get('is_agent') and actor.get('name'): + responder_name = actor.get('name') + break + + vals = { + 'name': ticket_data.get('subject') or self.name, + 'description': ticket_data.get('description_text') or '', + 'status': status_val, + 'priority': priority_val, + 'requester_name': requester_name, + 'requester_email': requester_email, + 'responder_name': responder_name, + 'department_name': department_name, + 'ticket_created_at': FreshserviceClient.parse_datetime(ticket_data.get('created_at')), + 'due_by': FreshserviceClient.parse_datetime(ticket_data.get('due_by')), + 'first_responded_at': FreshserviceClient.parse_datetime(stats.get('first_responded_at')), + 'resolved_at': FreshserviceClient.parse_datetime(stats.get('resolved_at')), + 'is_mock_data': use_mock, + 'is_detail_synced': True, + 'last_detail_sync': fields.Datetime.now(), + } + + self.write(vals) + + # 2. Rekonstruksi Audit Trail Activities menjadi Stage Logs. + # Jangan hapus snapshot lama sebelum payload terbukti memuat activities. + # Freshservice sesekali mengembalikan respons parsial/kosong; menghapus + # lebih dulu akan membuat seluruh Audit Trail hilang. + # Gunakan parser yang sama untuk key sorting karena beberapa payload/API + # dapat mencampur ISO string dengan datetime Python. + def _audit_datetime(value): + """Return naive UTC datetime for internal comparisons only.""" + if not value: + return False + if isinstance(value, datetime): + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + if isinstance(value, str): + clean = value.strip().replace('Z', '+00:00') + try: + parsed = datetime.fromisoformat(clean) + except ValueError: + try: + parsed = datetime.strptime(value.strip(), '%Y-%m-%d %H:%M:%S') + except ValueError: + return False + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + return False + + sorted_acts = sorted( + activities_data, + key=lambda x: _audit_datetime(x.get('created_at')) or datetime.min, + ) + has_activity_payload = bool(sorted_acts) + if not has_activity_payload: + _logger.warning( + 'Freshservice tidak mengembalikan activities untuk ticket #%s; ' + 'Audit Trail lama dipertahankan.', self.external_ticket_id, + ) + + # Buat pemetaan percakapan berdasarkan waktu (exact ISO dan menit) untuk pencocokan isi pesan balasan/note + conv_by_time = {} + for c in conversations_data: + c_time = c.get('created_at') + if c_time: + conv_by_time[c_time] = c + conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM) + + log_vals_list = [] + # Hanya perubahan status eksplisit yang mengubah state berikutnya. + effective_status = 'Open' + current_state = 'internal' + last_public_message = False + last_message_actor_type = False + first_ack_dt = False + first_meaningful_dt = False + meeting_wait_until = False + meeting_start_dt = False + meeting_waiting_for_followup = False + meeting_invitation_pattern = re.compile( + r'join\s+zoom\s+meeting|zoom\.us/j/|meeting\s+id\s*:|passcode\s*:|invitation\s+zoom', + re.IGNORECASE, + ) + meeting_time_pattern = re.compile( + r'Time:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}\s+[AP]M)', + re.IGNORECASE, + ) + for idx, act in enumerate(sorted_acts): + # datetime objects for comparisons; convert to Odoo strings only at write time. + start_dt_obj = _audit_datetime(act.get('created_at')) + start_dt = start_dt_obj.strftime('%Y-%m-%d %H:%M:%S') if start_dt_obj else False + # Waktu selesai adalah dimulainya aktivitas berikutnya + end_dt_obj = False + if idx + 1 < len(sorted_acts): + end_dt_obj = _audit_datetime(sorted_acts[idx + 1].get('created_at')) + elif self.status in ('resolved', 'closed'): + end_dt_obj = _audit_datetime(self.resolved_at) or start_dt_obj + end_dt = end_dt_obj.strftime('%Y-%m-%d %H:%M:%S') if end_dt_obj else False + + # 1. Ekstraksi Data Pelaku (Actor) + # Di Freshservice Live API, actor berupa dict: {"id": ..., "name": "...", "is_agent": True/False} + actor_dict = act.get('actor') if isinstance(act.get('actor'), dict) else {} + actor_name = (actor_dict.get('name') or act.get('actor_name') or '').strip() + actor_id = actor_dict.get('id') if actor_dict.get('id') is not None else act.get('actor_id') + is_agent = actor_dict.get('is_agent', False) + + if not actor_name: + if actor_id: + actor_name = str(actor_id) + else: + actor_name = 'System' + + # Tentukan actor_type + if actor_id == 0 or any(kw in actor_name.lower() for kw in ('ticket workflow', 'workflow', 'system')): + actor_type = 'System' + elif is_agent: + actor_type = 'Agent' + elif not is_agent and actor_id and actor_id != 0: + actor_type = 'Requester' + elif act.get('actor_type') in ('Agent', 'Requester', 'System'): + actor_type = act.get('actor_type') + elif self.requester_name and actor_name.lower() in self.requester_name.lower(): + actor_type = 'Requester' + elif self.responder_name and actor_name.lower() in self.responder_name.lower(): + actor_type = 'Agent' + else: + actor_type = 'Agent' + + # 2. Ekstraksi Deskripsi Aktivitas (Content + Sub-contents) + content_main = (act.get('content') or '').strip() + sub_contents = act.get('sub_contents') or [] + if not isinstance(sub_contents, list): + sub_contents = [] + + # Cari pesan percakapan/balasan jika aktivitas berkaitan dengan replied atau note + matched_msg = "" + act_created = act.get('created_at') or '' + if any(k in content_main.lower() for k in ['replied', 'reply', 'note']): + matched_conv = conv_by_time.get(act_created) or conv_by_time.get(act_created[:16]) + if matched_conv: + raw_body = (matched_conv.get('body_text') or '').strip() + if raw_body: + matched_msg = raw_body + + content_lines = [] + if content_main: + content_lines.append(content_main) + if matched_msg: + content_lines.append(f"Isi Pesan:\n\"{matched_msg}\"") + for sub in sub_contents: + if sub: + content_lines.append(f"• {str(sub).strip()}") + full_activity_content = "\n".join(content_lines) + + # 3. Tentukan stage_name + # Periksa apakah ada perubahan status eksplisit di sub_contents atau content + extracted_status = None + for sub in sub_contents: + if sub and 'set status as' in str(sub).lower(): + m = re.search(r'set Status as\s+([^(\n,]+)', str(sub), re.IGNORECASE) + if m: + extracted_status = m.group(1).strip() + break + + if not extracted_status and content_main and 'set status as' in content_main.lower(): + m = re.search(r'set Status as\s+([^(\n,]+)', content_main, re.IGNORECASE) + if m: + extracted_status = m.group(1).strip() + + stage_name = act.get('stage_name') + if not stage_name: + if extracted_status: + stage_name = extracted_status + elif content_main: + c_lower = content_main.lower() + if 'replied' in c_lower: + stage_name = 'Reply / Balasan' + elif 'private note' in c_lower: + stage_name = 'Private Note' + elif 'public note' in c_lower or 'note' in c_lower: + stage_name = 'Note' + elif 'executed' in c_lower and 'workflow' in c_lower: + stage_name = 'Workflow Trigger' + elif len(content_main) <= 50: + stage_name = content_main + else: + stage_name = content_main[:47] + '...' + else: + stage_name = 'Aktivitas' + + # 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification + # ============================================================================== + # Aturan A: Hanya event "set Status as X" yang mengubah effective_status. + # Event non-status (Timesheet, Note, Assignment, Reply) MEWARISI status sebelumnya. + # Aturan B: Ball-in-court check untuk IT_HOLD / Waiting on End User. + # Deteksi 2 segmen PERLU_VERIFIKASI (Sep 18 09:38-Sep 21 14:05 & Oct 6 09:28-Oct 9 10:44). + # Aturan C: Resolved oleh sistem/auto-rule → USER_SETELAH_AUTO_RESOLVE. + # Aturan D: Scheduled Meeting → BERSAMA dengan penanda delay_cause. + # Aturan E: First response substantif terpisah dari auto-ack. + # ============================================================================== + combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower() + status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower() + explicit_status = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) + vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text)) + is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I)) + + # Update pesan terakhir jika ada balasan/catatan publik + if matched_msg: + last_public_message = matched_msg + last_message_actor_type = actor_type + elif 'replied' in content_main.lower() or 'reply' in content_main.lower(): + last_public_message = content_main + last_message_actor_type = actor_type + + # Rekam First Response (Aturan E) + if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()): + is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting')) + if not first_ack_dt: + first_ack_dt = start_dt_obj + if not is_auto_ack and not first_meaningful_dt: + first_meaningful_dt = start_dt_obj + + classification_basis = "Status warisan: %s" % effective_status + needs_verification_flag = False + delay_cause = 'none' + + # Update effective_status hanya jika ada perubahan eksplisit + if vendor_external or (explicit_status and 'waiting on third party' in status_text): + effective_status = 'Waiting on Third Party' + current_state = 'third_party' + classification_basis = 'Status Freshservice: Waiting on Third Party' + elif explicit_status and 'waiting on internal discussion' in status_text: + effective_status = 'Waiting on Internal Discussion' + current_state = 'it_hold' + classification_basis = 'Status Freshservice: Waiting on Internal Discussion' + elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')): + effective_status = 'Waiting on End User' + current_state = 'customer' + classification_basis = 'Status Freshservice: Waiting on End User' + elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')): + effective_status = 'On Hold' + current_state = 'it_hold' + classification_basis = 'Status Freshservice: On Hold' + elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')): + is_auto_resolve = actor_type == 'System' or 'auto-resolve' in combined_text or 'workflow' in combined_text + effective_status = 'Resolved' + if is_auto_resolve: + current_state = 'user_after_auto_resolve' + classification_basis = 'Auto-resolve oleh sistem (menunggu respon user)' + else: + current_state = 'resolved_idle' + classification_basis = 'Status Freshservice: Resolved' + elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')): + effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open' + current_state = 'internal' + classification_basis = 'Status Freshservice: %s' % effective_status + + responsibility = current_state + + # Aturan B: status hold tidak otomatis berarti IT menunggu internal. + # Bila pesan publik terakhir dari Agent meminta konfirmasi user, klasifikasi + # dibuat eksplisit sebagai perlu verifikasi dan menyimpan kutipan pemicunya. + is_ball_req, basis_ball = self._ball_in_court_check( + effective_status, last_public_message, last_message_actor_type, + ) + if is_ball_req: + responsibility = 'needs_verification' + needs_verification_flag = True + classification_basis = basis_ball + + # Penanganan khusus Create Timesheet: MEWARISI status efektif (Aturan A) + if 'timesheet' in combined_text: + stage_name = 'Create Timesheet' + # Timesheet tidak mengubah responsibility, tetap memakai responsibility dari status efektif + classification_basis = 'Timesheet (mewarisi status efektif %s)' % effective_status + + # Penanganan balasan Agent: tidak membalik tanggung jawab jika status masih di user (Aturan A) + if actor_type == 'Agent' and any(k in content_main.lower() for k in ('replied', 'reply')) and current_state == 'customer': + responsibility = 'customer' + classification_basis = 'Balasan Agent (mewarisi status Waiting on End User)' + + # 4a. Smart Detection: Undangan Zoom / Meeting & Aturan Pembatalan Meeting (Cancel Meeting) + is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or '')) + is_meeting_cancelled = False + + # Cek apakah setelah link meeting dikirim, meeting dibatalkan atau digantikan oleh status resmi: + # 1. Perubahan status resmi selain "Waiting on Internal Discussion" (misal: In Progress, Waiting on End User, On Hold, Resolved). + # 2. Atau terdapat kata kunci pembatalan eksplisit (cancel/batal meeting/zoom/call). + if meeting_waiting_for_followup and not is_meeting_invitation: + has_explicit_status_change = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) or vendor_external or is_agent_assignment + is_internal_disc = 'waiting on internal discussion' in status_text.lower() + cancel_kw = bool(re.search(r'\b(batal|cancel|reschedule|tidak\s+jadi)\b.{0,30}\b(meeting|zoom|call|gmeet)\b', full_activity_content or '', re.I)) + + if has_explicit_status_change and not is_internal_disc: + # An explicit status transition ends the shared meeting phase, + # but it is not a cancellation by itself. The next status or + # activity is allowed to determine the normal responsibility. + meeting_waiting_for_followup = False + meeting_start_dt = False + meeting_wait_until = False + interval_meeting_start = False + + if cancel_kw: + # Only explicit cancellation wording marks the phase as + # cancelled in the audit trail. + is_meeting_cancelled = True + stage_name = f'Meeting Cancelled / {stage_name}' + meeting_waiting_for_followup = False + meeting_start_dt = False + meeting_wait_until = False + interval_meeting_start = False + + if is_meeting_invitation: + parsed_meeting_dt = False + time_match = meeting_time_pattern.search(full_activity_content or '') + if time_match: + try: + # Zoom time has no timezone in Freshservice text; it is interpreted as WIB then stored UTC. + parsed_meeting_dt = datetime.strptime( + time_match.group(1).title(), '%b %d, %Y %I:%M %p' + ) - timedelta(hours=7) + except ValueError: + _logger.warning('Unable to parse scheduled Zoom time %r for ticket %s', time_match.group(1), self.external_ticket_id) + meeting_start_dt = parsed_meeting_dt + meeting_waiting_for_followup = True + meeting_wait_until = False + # Aktivitas pengiriman link dibebankan kepada pengirimnya. + # Jika Agent yang mengirim, stage log ini berwarna Internal (merah) + # di Excel. Fase Waiting Together baru dimulai setelah aktivitas ini. + responsibility = 'internal' if actor_type == 'Agent' else current_state + + if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until: + meeting_wait_until = False + + # Keep the boundary locally because this activity can close the meeting phase. + interval_meeting_start = meeting_start_dt + is_agent_followup = actor_type == 'Agent' and any( + k in (content_main or '').lower() for k in ('replied', 'reply', 'note') + ) + if is_meeting_invitation: + # Pengiriman link adalah pekerjaan pembuat link. Jangan mengubahnya + # menjadi Waiting Together; status meeting dipakai aktivitas berikutnya. + responsibility = 'internal' if actor_type == 'Agent' else current_state + meeting_wait_until = False + elif is_meeting_cancelled: + # Tanggung jawab kembali ke status operasional saat ini (current_state) + responsibility = current_state + elif ( + meeting_waiting_for_followup + and not is_meeting_invitation + and 'waiting on internal discussion' in status_text.lower() + ): + # Setelah link meeting dikirim, status ini masih merupakan + # fase Scheduled Meeting dan tidak membatalkan state meeting. + responsibility = 'scheduled_meeting' + elif meeting_waiting_for_followup: + # Keep the complete interval shared while the ticket remains in + # Internal Discussion or Scheduled Meeting. Do not split at the + # scheduled meeting start; the next explicit status/activity + # determines the new responsibility. + responsibility = 'scheduled_meeting' + + # 5. Set First Responder jika belum terdeteksi + if not self.first_responder_name and actor_type == 'Agent': + if 'replied' in content_main.lower() or 'reply' in content_main.lower() or not any('replied' in (a.get('content') or '').lower() for a in sorted_acts): + self.first_responder_name = actor_name + + log_values = { + 'stage_name': stage_name, + 'responsibility': responsibility, + 'effective_status': effective_status, + 'classification_basis': classification_basis, + 'needs_verification': needs_verification_flag, + 'delay_cause': delay_cause, + 'actor_name': actor_name, + 'actor_type': actor_type, + 'activity_content': full_activity_content or stage_name, + 'start_date': start_dt, + 'end_date': end_dt, + } + # Keep one continuous shared-time interval while the effective status is + # Internal Discussion or Scheduled Meeting. The interval changes only + # when the next activity/status explicitly changes responsibility. + log_vals_list.append((0, 0, log_values)) + + ticket_write_vals = {} + if first_ack_dt: + ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S') + if first_meaningful_dt: + ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S') + + if log_vals_list: + # Baru ganti snapshot setelah daftar audit trail pengganti siap. + self.stage_log_ids.unlink() + ticket_write_vals['stage_log_ids'] = log_vals_list + self.write(ticket_write_vals) + elif ticket_write_vals: + self.write(ticket_write_vals) + + # 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong. + conv_vals_list = [] + for conv in conversations_data: + conv_vals_list.append((0, 0, { + 'external_id': str(conv.get('id') or ''), + 'user_name': conv.get('user_name') or 'User', + 'user_id_external': str(conv.get('user_id') or ''), + 'incoming': conv.get('incoming', False), + 'body_text': conv.get('body_text') or '', + 'created_at': FreshserviceClient.parse_datetime(conv.get('created_at')), + })) + + if conv_vals_list: + self.conversation_ids.unlink() + self.write({'conversation_ids': conv_vals_list}) + elif not conversations_data: + _logger.warning( + 'Freshservice tidak mengembalikan conversations untuk ticket #%s; ' + 'riwayat percakapan lama dipertahankan.', self.external_ticket_id, + ) + + mode_text = "LIVE FRESHSERVICE API" + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': f'Sinkronisasi Sukses [{mode_text}]', + 'message': f'Data Tiket #{self.external_ticket_id} dan seluruh audit trail durasi berhasil dimutakhirkan!', + 'type': 'success', + 'sticky': False, + } + } + + # ========================================================================= + # AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION + # ========================================================================= + @api.model + def _auto_sync_recent_tickets(self, limit=None, raise_if_empty=False, tiket_id=None): + """ + Otomatis fetch daftar tiket terbaru (hanya ringkasan dan status) dari Freshservice. + Batas jumlah tiket (limit) dibaca dari konfigurasi Settings (default 200). + Detail lengkap (activities, stage logs, conversations) HANYA di-fetch on-demand saat user + membuka halaman detail (form view) tiket terkait. + Dilengkapi throttling 5 detik agar aman bagi multi-user dan tidak membebani API. + """ + if getattr(self.env.cr, 'readonly', False): + with self.env.registry.cursor(readonly=False) as rw_cr: + rw_env = self.env(cr=rw_cr) + res = rw_env['freshservice.ticket']._auto_sync_recent_tickets(limit=limit, raise_if_empty=raise_if_empty) + rw_cr.commit() + self.invalidate_recordset() + return res + + param_obj = self.env['ir.config_parameter'].sudo() + if not limit: + try: + limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200) + except (ValueError, TypeError): + limit = 200 + if limit <= 0: + limit = 200 + + now_ts = fields.Datetime.now().timestamp() + last_sync = param_obj.get_param('gmn_freshservice.last_list_sync_time') + if last_sync: + try: + # Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir + if now_ts - float(last_sync) < 5: + return 0 + except Exception: + pass + param_obj.set_param('gmn_freshservice.last_list_sync_time', str(now_ts)) + + base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '') + use_mock = False + + try: + client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=False) + tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id) + except Exception as e: + _logger.warning("Auto-fetch Freshservice gagal: %s", e) + if raise_if_empty: + raise UserError(str(e)) + return 0 + + if not tickets_list: + if raise_if_empty: + raise UserError(_("Tidak ada tiket yang ditemukan dari Freshservice. Pastikan URL dan API Key di Settings sudah benar.")) + return 0 + + ext_ids = [str(t.get('id')) for t in tickets_list if t.get('id')] + existing_tickets = { + rec.external_ticket_id: rec + for rec in self.search([('external_ticket_id', 'in', ext_ids)]) + } + + to_create = [] + synced_count = 0 + + for t in tickets_list: + ext_id = str(t.get('id') or '') + if not ext_id: + continue + + stats = t.get('stats', {}) if isinstance(t.get('stats'), dict) else {} + status_code = t.get('status') + status_val = FreshserviceClient.STATUS_MAP.get(status_code, 'open') + priority_code = t.get('priority') + priority_val = FreshserviceClient.PRIORITY_MAP.get(priority_code, 'medium') + + created_at_dt = FreshserviceClient.parse_datetime(t.get('created_at')) + due_by_dt = FreshserviceClient.parse_datetime(t.get('due_by')) + fr_dt = FreshserviceClient.parse_datetime(stats.get('first_responded_at')) + res_dt = FreshserviceClient.parse_datetime(stats.get('resolved_at')) + + vals = { + 'name': t.get('subject') or f"Tiket #{ext_id}", + 'status': status_val, + 'priority': priority_val, + 'ticket_created_at': created_at_dt, + 'due_by': due_by_dt, + 'first_responded_at': fr_dt, + 'resolved_at': res_dt, + 'is_mock_data': use_mock, + } + if t.get('responder_name'): + vals['responder_name'] = t.get('responder_name') + if t.get('requester_name'): + vals['requester_name'] = t.get('requester_name') + if t.get('requester_email'): + vals['requester_email'] = t.get('requester_email') + if t.get('department_name'): + vals['department_name'] = t.get('department_name') + if t.get('description_text'): + vals['description'] = t.get('description_text') + + if ext_id in existing_tickets: + rec = existing_tickets[ext_id] + write_vals = {} + for k, v in vals.items(): + if getattr(rec, k, None) != v: + write_vals[k] = v + if write_vals: + # Jika status tiket di Freshservice berubah, reset is_detail_synced agar saat detail dibuka di-sync ulang + if 'status' in write_vals and rec.is_detail_synced: + write_vals['is_detail_synced'] = False + rec.write(write_vals) + synced_count += 1 + else: + vals['external_ticket_id'] = ext_id + vals['is_detail_synced'] = False + to_create.append(vals) + synced_count += 1 + + if to_create: + self.create(to_create) + + return synced_count + + @api.model + def _extract_ticket_ids_from_domain(self, domain): + """Cari angka-angka ticket ID dari domain pencarian Odoo. + Contoh: domain [('external_ticket_id', '=', '992')] → return ['992'] + """ + ticket_ids = [] + if not domain: + return ticket_ids + + def _walk(node): + if isinstance(node, (list, tuple)): + if len(node) == 3 and isinstance(node[0], str): + field_name, operator, value = node + if field_name in ('external_ticket_id', 'name', 'id') and value is not None: + candidates = re.findall(r'\d+', str(value)) + for candidate in candidates: + if candidate and candidate not in ticket_ids: + ticket_ids.append(candidate) + return + for child in node: + _walk(child) + + _walk(domain) + return ticket_ids + + @api.model + def _fetch_and_create_ticket_by_external_id(self, ticket_id): + """Cari tiket di Odoo berdasarkan ID Freshservice. + Kalau belum ada, buat record baru lalu langsung sync dari API. + """ + if ticket_id in (None, ''): + return self.browse() + + clean_id = str(ticket_id).strip().lstrip('#') + if not clean_id or not clean_id.isdigit(): + return self.browse() + + if getattr(self.env.cr, 'readonly', False): + with self.env.registry.cursor(readonly=False) as rw_cr: + rw_env = self.env(cr=rw_cr) + res = rw_env['freshservice.ticket']._fetch_and_create_ticket_by_external_id(clean_id) + rw_cr.commit() + self.invalidate_recordset() + return res + + existing = self.search([('external_ticket_id', '=', clean_id)], limit=1) + if existing: + return existing + + try: + ticket = self.create({ + 'name': f'Tiket Freshservice #{clean_id}', + 'external_ticket_id': clean_id, + 'is_detail_synced': False, + }) + ticket.action_sync_from_freshservice() + return ticket + except Exception as e: + _logger.warning('Gagal fetch on-demand ticket #%s dari Freshservice: %s', clean_id, e) + return self.browse() + + @api.model + def action_open_and_fetch_tickets(self, tiket_id = None): + """Dipanggil saat user klik menu 'Daftar Tiket'. + Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list. + """ + try: + self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id) + except Exception as e: + _logger.warning("Auto fetch saat buka menu tiket: %s", e) + + action = self.env['ir.actions.act_window']._for_xml_id('gmn_freshservice.action_freshservice_ticket') + param_obj = self.env['ir.config_parameter'].sudo() + try: + sync_limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200) + if sync_limit > 0: + action['limit'] = sync_limit + except Exception: + pass + return action + + @api.model + def name_search(self, name='', domain=None, operator='ilike', limit=100, **kwargs): + """Override name_search: kalau user ketik nomor tiket yang belum ada di Odoo, + otomatis fetch dari Freshservice dulu baru tampilkan. + """ + if domain is None and 'args' in kwargs: + domain = kwargs.pop('args') + if name and isinstance(name, str): + ticket_ids = re.findall(r'\d+', name.strip()) + for ticket_id in ticket_ids: + if not self.search_count([('external_ticket_id', '=', ticket_id)]): + self._fetch_and_create_ticket_by_external_id(ticket_id) + return super().name_search(name=name, domain=domain, operator=operator, limit=limit, **kwargs) + + @api.model + def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): + """Override web_search_read: otomatis sync tiket terbaru dari Freshservice + setiap kali user buka atau refresh halaman list tiket. + Juga cek kalau ada nomor tiket di pencarian yang belum ada di Odoo. + """ + if offset == 0 and not self.env.context.get('skip_auto_fetch'): + try: + self.with_context(skip_auto_fetch=True)._auto_sync_recent_tickets(limit=None, raise_if_empty=False) + except Exception as e: + _logger.warning("Auto fetch di web_search_read: %s", e) + + for ticket_id in self._extract_ticket_ids_from_domain(domain): + if not self.search_count([('external_ticket_id', '=', ticket_id)]): + self._fetch_and_create_ticket_by_external_id(ticket_id) + + return super().web_search_read( + domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit + ) + + @api.readonly + def web_read(self, specification: dict[str, dict]) -> list[dict]: + """Override web_read: saat user buka detail tiket (form view), + kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice. + """ + if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'): + # Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail + is_detail_view = any(f in specification for f in ('stage_log_ids', 'conversation_ids', 'description')) + if is_detail_view and not self.is_detail_synced: + try: + if getattr(self.env.cr, 'readonly', False): + with self.env.registry.cursor(readonly=False) as rw_cr: + rw_env = self.env(cr=rw_cr) + rw_env['freshservice.ticket'].browse(self.id).with_context(skip_auto_detail_fetch=True).action_sync_from_freshservice() + rw_cr.commit() + self.invalidate_recordset() + else: + self.with_context(skip_auto_detail_fetch=True).action_sync_from_freshservice() + except Exception as e: + _logger.warning("Gagal auto-fetch detail saat membuka form tiket #%s: %s", self.external_ticket_id, e) + + return super().web_read(specification) + + def action_sync_recent_tickets(self): + """Tombol manual 'Tarik Tiket Terbaru' di halaman list. + Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice. + """ + synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Sinkronisasi Daftar Tiket Berhasil'), + 'message': _('Berhasil memuat %d tiket terbaru dari Freshservice!', synced_count), + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.client', 'tag': 'reload'}, + } + } diff --git a/gmn_freshservice/models/gmn_res_config_settings_v01.py b/gmn_freshservice/models/gmn_res_config_settings_v01.py new file mode 100644 index 0000000..a10e7d0 --- /dev/null +++ b/gmn_freshservice/models/gmn_res_config_settings_v01.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""Konfigurasi Settings untuk koneksi Freshservice. +User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo. +""" +from odoo import models, fields, api +from odoo.exceptions import UserError +import requests + + +class ResConfigSettings(models.TransientModel): + """Halaman Settings untuk mengatur koneksi ke Freshservice API. + Bisa diakses dari menu Settings > Freshservice. + """ + _inherit = 'res.config.settings' + + freshservice_url = fields.Char( + string='Freshservice URL', + config_parameter='gmn_freshservice.freshservice_url', + default='https://ptglobalmultimedianetwork.freshservice.com' + ) + freshservice_api_key = fields.Char( + string='Freshservice API Key', + config_parameter='gmn_freshservice.freshservice_api_key' + ) + freshservice_sync_limit = fields.Integer( + string='Limit Sinkronisasi Tiket', + config_parameter='gmn_freshservice.freshservice_sync_limit', + default=200, + help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).' + ) + freshservice_use_mock = fields.Boolean( + string='Gunakan Mock Data Engine', + config_parameter='gmn_freshservice.freshservice_use_mock', + default=False, + readonly=True, + help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.' + ) + + def action_test_freshservice_connection(self): + """Tombol 'Test Connection' di halaman Settings. + Coba panggil API Freshservice untuk cek apakah URL dan API Key valid. + """ + self.ensure_one() + url = (self.freshservice_url or "").rstrip("/") + if not url: + raise UserError("URL Freshservice tidak boleh kosong!") + + if self.freshservice_use_mock: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Mode Mock Aktif', + 'message': 'Engine simulasi aktif. Anda dapat langsung menarik tiket contoh tanpa API Key!', + 'type': 'success', + 'sticky': False, + } + } + + if not self.freshservice_api_key: + raise UserError("API Key wajib diisi untuk pengujian Live API!") + + try: + auth = (self.freshservice_api_key, 'X') + headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} + res = requests.get(f"{url}/api/v2/tickets?per_page=1", auth=auth, headers=headers, timeout=10) + if res.status_code == 200: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Koneksi Berhasil!', + 'message': f'Berhasil terhubung ke Live Freshservice API ({url}).', + 'type': 'success', + 'sticky': False, + } + } + else: + raise UserError(f"Gagal terhubung (HTTP {res.status_code}): {res.text}") + except Exception as e: + raise UserError(f"Terjadi kesalahan koneksi: {str(e)}") diff --git a/gmn_freshservice/security/gmn_freshservice_security_v01.xml b/gmn_freshservice/security/gmn_freshservice_security_v01.xml new file mode 100644 index 0000000..0e6955b --- /dev/null +++ b/gmn_freshservice/security/gmn_freshservice_security_v01.xml @@ -0,0 +1,39 @@ + + + + + Freshservice + Modul Analisis Bottleneck & Audit Trail Tiket Freshservice + 25 + + + + + Viewer / Agent IT + + + Hak baca (Read) pada seluruh tiket, audit trail, percakapan, dan hari libur. Dapat mengakses wizard bottleneck report untuk generate laporan Excel. + + + + + Supervisor / IT Manager + + + + Hak penuh (CRUD) serta hak mengeksekusi sinkronisasi API, manual override, impor hari libur, dan ekspor laporan bottleneck. + + diff --git a/gmn_freshservice/security/ir.model.access.csv b/gmn_freshservice/security/ir.model.access.csv new file mode 100644 index 0000000..b529a07 --- /dev/null +++ b/gmn_freshservice/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_freshservice_ticket_user,freshservice.ticket.user,model_freshservice_ticket,gmn_freshservice.group_freshservice_user,1,0,0,0 +access_freshservice_ticket_manager,freshservice.ticket.manager,model_freshservice_ticket,gmn_freshservice.group_freshservice_manager,1,1,1,1 +access_freshservice_ticket_stage_log_user,freshservice.ticket.stage.log.user,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_user,1,0,0,0 +access_freshservice_ticket_stage_log_manager,freshservice.ticket.stage.log.manager,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_manager,1,1,1,1 +access_freshservice_ticket_conversation_user,freshservice.ticket.conversation.user,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_user,1,1,1,1 +access_freshservice_ticket_conversation_manager,freshservice.ticket.conversation.manager,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_manager,1,1,1,1 +access_freshservice_holiday_user,freshservice.holiday.user,model_freshservice_holiday,gmn_freshservice.group_freshservice_user,1,0,0,0 +access_freshservice_holiday_manager,freshservice.holiday.manager,model_freshservice_holiday,gmn_freshservice.group_freshservice_manager,1,1,1,1 +access_freshservice_holiday_import_wizard_manager,freshservice.holiday.import.wizard.manager,model_freshservice_holiday_import_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1 +access_freshservice_ticket_bottleneck_wizard_user,freshservice.ticket.bottleneck.wizard.user,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_user,1,1,1,1 +access_freshservice_ticket_bottleneck_wizard_manager,freshservice.ticket.bottleneck.wizard.manager,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1 diff --git a/gmn_freshservice/services/__init__.py b/gmn_freshservice/services/__init__.py new file mode 100644 index 0000000..156ac9d --- /dev/null +++ b/gmn_freshservice/services/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Package services: berisi client untuk koneksi ke Freshservice API.""" +from . import gmn_freshservice_client_v01 diff --git a/gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc b/gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..796d39f Binary files /dev/null and b/gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..bad2a3e Binary files /dev/null and b/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc new file mode 100644 index 0000000..0c8ce89 Binary files /dev/null and b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc differ diff --git a/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc new file mode 100644 index 0000000..61a6cd2 Binary files /dev/null and b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc differ diff --git a/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc b/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc new file mode 100644 index 0000000..1c0ea2e Binary files /dev/null and b/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/services/gmn_freshservice_client_v01.py b/gmn_freshservice/services/gmn_freshservice_client_v01.py new file mode 100644 index 0000000..28d8010 --- /dev/null +++ b/gmn_freshservice/services/gmn_freshservice_client_v01.py @@ -0,0 +1,652 @@ +# -*- coding: utf-8 -*- +"""Client untuk koneksi ke Freshservice REST API v2. +Bisa jalan dalam 2 mode: Live (panggil API beneran) atau Mock (data dummy untuk testing). +""" +import datetime +import logging +import requests +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +class FreshserviceClient: + """ + Client adapter untuk integrasi Freshservice API v2. + Mendukung dua mode: + 1. Mock Mode (Format data 100% identik dengan dokumentasi resmi Freshservice API) + 2. Live API Mode (Direct HTTP call ke endpoint perusahaan) + """ + + STATUS_MAP = { + 2: 'open', + 3: 'pending', + 4: 'resolved', + 5: 'closed', + } + + PRIORITY_MAP = { + 1: 'low', + 2: 'medium', + 3: 'high', + 4: 'urgent', + } + + _agent_cache = {} + _dept_cache = {} + + def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False): + """Inisialisasi koneksi ke Freshservice. + - base_url: URL instance Freshservice (misal: https://company.freshservice.com) + - api_key: token API untuk otentikasi + - use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet + """ + self.base_url = (base_url or "").rstrip("/") + self.api_key = api_key or "" + self.use_mock = False + + def get_agent_name(self, agent_id): + """Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)""" + if not agent_id or self.use_mock: + return "" + if agent_id in self._agent_cache: + return self._agent_cache[agent_id] + + try: + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + url = f"{self.base_url}/api/v2/agents/{agent_id}" + res = requests.get(url, auth=auth, headers=headers, timeout=10) + if res.status_code == 200: + agent = res.json().get("agent", {}) + first_name = agent.get("first_name") or "" + last_name = agent.get("last_name") or "" + full_name = f"{first_name} {last_name}".strip() or agent.get("email") or str(agent_id) + self._agent_cache[agent_id] = full_name + return full_name + except Exception as e: + _logger.warning("Gagal fetch agent %s dari Freshservice: %s", agent_id, e) + + self._agent_cache[agent_id] = "" + return "" + + def get_department_name(self, dept_id): + """Ambil nama departemen Freshservice berdasarkan ID (dengan in-memory cache)""" + if not dept_id or self.use_mock: + return "" + if dept_id in self._dept_cache: + return self._dept_cache[dept_id] + + try: + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + url = f"{self.base_url}/api/v2/departments/{dept_id}" + res = requests.get(url, auth=auth, headers=headers, timeout=10) + if res.status_code == 200: + dept = res.json().get("department", {}) + name = dept.get("name") or "" + self._dept_cache[dept_id] = name + return name + except Exception as e: + _logger.warning("Gagal fetch department %s dari Freshservice: %s", dept_id, e) + + self._dept_cache[dept_id] = "" + return "" + + def fetch_ticket_full_data(self, ticket_id): + """ + Mengambil paket lengkap data tiket: + - Ticket Details + SLA Stats + - Audit Trail Activities + - Conversations (Replies & Notes) + """ + if self.use_mock: + _logger.info("Mengambil data tiket Freshservice via MOCK ENGINE untuk ID: %s", ticket_id) + return self._get_mock_payload(ticket_id) + else: + _logger.info("Menghubungi LIVE Freshservice API: %s untuk ID: %s", self.base_url, ticket_id) + return self._get_live_payload(ticket_id) + + def fetch_recent_tickets(self, limit=200, tiket_id=None): + """ + Mengambil daftar tiket terbaru dari Freshservice API (default 200 tiket terbaru). + Freshservice API v2 membatasi per_page maksimum 100 tiket per request, + sehingga dilakukan paginasi (page 1, page 2, dst) secara cepat hingga mencapai limit. + Hanya mengambil ringkasan tiket dan status (tanpa membebani API dengan detail activities). + """ + if self.use_mock: + return self._get_mock_recent_tickets(limit=limit) + + if not self.base_url or not self.api_key: + raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!") + + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + + tickets = [] + page = 1 + per_page = 100 + while len(tickets) < limit: + fetch_count = min(per_page, limit - len(tickets)) + url = f"{self.base_url}/api/v2/tickets?order_by=created_at&order_type=desc&per_page={fetch_count}&page={page}&include=requester,stats" + try: + res = requests.get(url, auth=auth, headers=headers, timeout=25) + if res.status_code == 200: + batch = res.json().get("tickets", []) + if not batch: + break + + # Resolusi info pemohon, agen responder, dan departemen + for t in batch: + req = t.get('requester') or {} + t['requester_name'] = req.get('name') or t.get('requester_name') or '' + t['requester_email'] = req.get('email') or t.get('requester_email') or t.get('email') or '' + + resp_id = t.get('responder_id') + if resp_id: + t['responder_name'] = self.get_agent_name(resp_id) + + dept_id = t.get('department_id') + if dept_id: + t['department_name'] = self.get_department_name(dept_id) + elif t.get('custom_fields', {}).get('company_information_for_owner_of_ticket'): + t['department_name'] = t['custom_fields']['company_information_for_owner_of_ticket'] + + tickets.extend(batch) + if len(batch) < fetch_count: + # Data di Freshservice sudah habis + break + page += 1 + else: + _logger.warning("Gagal fetch recent tickets halaman %s (HTTP %s): %s", page, res.status_code, res.text) + break + except Exception as e: + _logger.error("Error fetching recent tickets from Freshservice: %s", e) + break + + return tickets[:limit] + + def _get_mock_recent_tickets(self, limit=200): + """Mock data ringkasan tiket untuk pengujian cepat tanpa membebani network""" + samples = [ + { + "id": 129, + "subject": "Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #129]", + "status": 4, + "priority": 3, + "requester_name": "Ahmad Dani (User)", + "responder_name": "Budi Santoso (IT Support)", + "department_name": "Finance & Accounting", + "created_at": "2026-10-10T08:30:00Z", + "due_by": "2026-10-12T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-10T08:52:00Z", + "resolved_at": "2026-10-11T16:30:00Z", + } + }, + { + "id": 9991, + "subject": "User Akses untuk modul report Account Receivables", + "status": 2, + "priority": 1, + "requester_name": "Henny Gunawan", + "responder_name": "Jonathan Tan", + "department_name": "Accounting", + "created_at": "2026-10-12T02:45:00Z", + "due_by": "2026-10-14T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-12T03:15:00Z", + } + }, + { + "id": 9966, + "subject": "Email gom@parahyangangolf.com - Your Certificate has Expired", + "status": 4, + "priority": 1, + "requester_name": "Rohmat tulloh", + "responder_name": "Prabowo Bowo", + "department_name": "IT Infrastructure", + "created_at": "2026-10-09T06:27:00Z", + "due_by": "2026-10-11T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-09T07:10:00Z", + "resolved_at": "2026-10-09T09:00:00Z", + } + }, + { + "id": 9950, + "subject": "Permintaan Install Ulang Windows Laptop Sales", + "status": 3, + "priority": 2, + "requester_name": "Siti Nurhaliza", + "responder_name": "Budi Santoso (IT Support)", + "department_name": "Sales & Marketing", + "created_at": "2026-10-11T09:15:00Z", + "due_by": "2026-10-13T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-11T10:00:00Z", + } + }, + { + "id": 9942, + "subject": "Reset Password Akun VPN Kantor Pusat", + "status": 5, + "priority": 2, + "requester_name": "Rudi Hermawan", + "responder_name": "Prabowo Bowo", + "department_name": "Human Resources", + "created_at": "2026-10-08T11:00:00Z", + "due_by": "2026-10-09T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-08T11:15:00Z", + "resolved_at": "2026-10-08T11:45:00Z", + } + }, + ] + return samples[:limit] + + # def _get_live_payload(self, ticket_id): + # if not self.base_url or not self.api_key: + # raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!") + + # auth = (self.api_key, "X") + # headers = {"Content-Type": "application/json", "Accept": "application/json"} + + # # 1. Ticket details & stats with requester + # url_ticket = f"{self.base_url}/api/v2/tickets/{ticket_id}?include=requester,stats" + # res_ticket = requests.get(url_ticket, auth=auth, headers=headers, timeout=20) + # if res_ticket.status_code != 200: + # raise UserError(f"Gagal mengambil tiket dari Freshservice (HTTP {res_ticket.status_code}): {res_ticket.text}") + # ticket_json = res_ticket.json().get("ticket", {}) + + # # Resolusi data pemohon (requester), agen (responder), dan departemen + # req = ticket_json.get('requester') or {} + # ticket_json['requester_name'] = req.get('name') or ticket_json.get('requester_name') or '' + # ticket_json['requester_email'] = req.get('email') or ticket_json.get('requester_email') or ticket_json.get('email') or '' + + # resp_id = ticket_json.get('responder_id') + # if resp_id: + # ticket_json['responder_name'] = self.get_agent_name(resp_id) + + # dept_id = ticket_json.get('department_id') + # if dept_id: + # ticket_json['department_name'] = self.get_department_name(dept_id) + # elif ticket_json.get('custom_fields', {}).get('company_information_for_owner_of_ticket'): + # ticket_json['department_name'] = ticket_json['custom_fields']['company_information_for_owner_of_ticket'] + + # # 2. Activities (Audit Trail) with pagination support + # url_activities = f"{self.base_url}/api/v2/tickets/{ticket_id}/activities" + # activities_json = [] + # page_count = 0 + # while url_activities and page_count < 10: + # page_count += 1 + # try: + # res_act = requests.get(url_activities, auth=auth, headers=headers, timeout=20) + # if res_act.status_code == 200: + # act_data = res_act.json() + # activities_json.extend(act_data.get("activities", [])) + # url_activities = act_data.get("next_page_url") + # else: + # _logger.warning("Gagal fetch activities Freshservice (HTTP %s): %s", res_act.status_code, res_act.text) + # break + # except Exception as e: + # _logger.warning("Exception saat fetch activities halaman %s: %s", page_count, e) + # break + + # # 3. Conversations + # url_conv = f"{self.base_url}/api/v2/tickets/{ticket_id}/conversations" + # res_conv = requests.get(url_conv, auth=auth, headers=headers, timeout=20) + # conversations_json = res_conv.json().get("conversations", []) if res_conv.status_code == 200 else [] + + # return { + # "ticket": ticket_json, + # "activities": activities_json, + # "conversations": conversations_json, + # } + def _get_live_payload(self, ticket_id): + """Ambil data lengkap tiket dari server live Freshservice via REST API. + Memanggil beberapa endpoint: + 1. /api/v2/tickets/{id}?include=stats,requester - info tiket & metrik + 2. /api/v2/tickets/{id}/activities - audit trail/riwayat perubahan + 3. /api/v2/tickets/{id}/conversations - riwayat pesan/chat + Mengembalikan dictionary berisi ketiga data di atas. + """ + if not self.base_url or not self.api_key: + raise UserError( + "Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!" + ) + + auth = (self.api_key, "X") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + # 1. Ticket details & stats with requester + url_ticket = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}?include=requester,stats" + ) + + res_ticket = requests.get( + url_ticket, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_ticket.status_code != 200: + raise UserError( + f"Gagal mengambil tiket dari Freshservice " + f"(HTTP {res_ticket.status_code}): {res_ticket.text}" + ) + + ticket_json = res_ticket.json().get("ticket", {}) + + # Resolusi data pemohon (requester), agen (responder), dan departemen + req = ticket_json.get("requester") or {} + + ticket_json["requester_name"] = ( + req.get("name") + or ticket_json.get("requester_name") + or "" + ) + + ticket_json["requester_email"] = ( + req.get("email") + or ticket_json.get("requester_email") + or ticket_json.get("email") + or "" + ) + + resp_id = ticket_json.get("responder_id") + if resp_id: + ticket_json["responder_name"] = self.get_agent_name(resp_id) + + dept_id = ticket_json.get("department_id") + if dept_id: + ticket_json["department_name"] = self.get_department_name(dept_id) + elif ticket_json.get("custom_fields", {}).get( + "company_information_for_owner_of_ticket" + ): + ticket_json["department_name"] = ticket_json["custom_fields"][ + "company_information_for_owner_of_ticket" + ] + + # 2. Activities (Audit Trail) + # Fetch seluruh halaman sampai next_page_url kosong + url_activities = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}/activities" + ) + + activities_json = [] + visited_urls = set() + + while url_activities: + # Mencegah infinite loop jika URL pagination berulang + if url_activities in visited_urls: + _logger.warning( + "Pagination activities dihentikan karena URL berulang: %s", + url_activities, + ) + break + + visited_urls.add(url_activities) + + try: + res_act = requests.get( + url_activities, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_act.status_code != 200: + _logger.warning( + "Gagal fetch activities Freshservice (HTTP %s): %s", + res_act.status_code, + res_act.text, + ) + break + + act_data = res_act.json() + + # Tambahkan activities dari halaman saat ini + activities_json.extend( + act_data.get("activities", []) + ) + + # Lanjut sampai halaman terakhir + url_activities = act_data.get("next_page_url") + + except Exception as e: + _logger.warning( + "Exception saat fetch activities Freshservice: %s", + e, + ) + break + + _logger.info( + "Freshservice ticket %s: berhasil mengambil %s activities", + ticket_id, + len(activities_json), + ) + + # 3. Conversations + url_conv = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}/conversations" + ) + + conversations_json = [] + + try: + res_conv = requests.get( + url_conv, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_conv.status_code == 200: + conversations_json = res_conv.json().get( + "conversations", [] + ) + else: + _logger.warning( + "Gagal fetch conversations Freshservice " + "(HTTP %s): %s", + res_conv.status_code, + res_conv.text, + ) + + except Exception as e: + _logger.warning( + "Exception saat fetch conversations Freshservice: %s", + e, + ) + + return { + "ticket": ticket_json, + "activities": activities_json, + "conversations": conversations_json, + } + + def _get_mock_payload(self, ticket_id): + """ + Data Mock standar enterprise sesuai dokumentasi Freshservice API v2. + Merefleksikan siklus tiket riil: + - Open (08:30) + - First Response oleh Agent (08:52) -> jeda 22 menit + - Status Pending User (09:30) -> menunggu klarifikasi user + - User Balas (Keesokan hari 14:00) -> 28.5 jam di user + - Status In Progress (14:00) -> 2.5 jam di internal + - Status Resolved (16:30) + """ + str_id = str(ticket_id or "129") + + return { + "ticket": { + "id": int(str_id) if str_id.isdigit() else 129, + "subject": f"Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #{str_id}]", + "description_text": "Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.", + "status": 4, # 4 = Resolved + "priority": 3, # 3 = High + "source": 2, # Portal + "requester_id": 19000123456, + "requester_name": "Ahmad Dani (User)", + "requester_email": "ahmad.dani@lyman.co.id", + "responder_id": 19000234567, + "responder_name": "Budi Santoso (IT Support)", + "responder_email": "budi.santoso@lyman.co.id", + "department_name": "Finance & Accounting", + "created_at": "2026-10-10T08:30:00Z", + "updated_at": "2026-10-11T16:30:00Z", + "due_by": "2026-10-12T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-10T08:52:00Z", + "resolved_at": "2026-10-11T16:30:00Z", + "closed_at": None, + "pending_since": "2026-10-10T09:30:00Z", + } + }, + "activities": [ + { + "id": 101, + "actor": { + "id": 19000123456, + "name": "Ahmad Dani (User)", + "is_agent": False + }, + "actor_id": 19000123456, + "actor_type": "Requester", + "actor_name": "Ahmad Dani (User)", + "content": "Tiket dibuat via Web Portal dengan status Open", + "stage_name": "Open", + "responsibility": "internal", + "created_at": "2026-10-10T08:30:00Z" + }, + { + "id": 102, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.", + "stage_name": "In Progress", + "responsibility": "internal", + "created_at": "2026-10-10T08:52:00Z" + }, + { + "id": 103, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.", + "stage_name": "Pending User", + "responsibility": "customer", + "created_at": "2026-10-10T09:30:00Z" + }, + { + "id": 104, + "actor": { + "id": 19000123456, + "name": "Ahmad Dani (User)", + "is_agent": False + }, + "actor_id": 19000123456, + "actor_type": "Requester", + "actor_name": "Ahmad Dani (User)", + "content": "User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.", + "stage_name": "In Progress", + "responsibility": "internal", + "created_at": "2026-10-11T14:00:00Z" + }, + { + "id": 105, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.", + "stage_name": "Resolved", + "responsibility": "internal", + "created_at": "2026-10-11T16:30:00Z" + } + ], + "conversations": [ + { + "id": 501, + "body_text": "Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-10T08:52:00Z" + }, + { + "id": 502, + "body_text": "Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-10T09:30:00Z" + }, + { + "id": 503, + "body_text": "Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.", + "incoming": True, + "user_id": 19000123456, + "user_name": "Ahmad Dani (User)", + "created_at": "2026-10-11T14:00:00Z" + }, + { + "id": 504, + "body_text": "Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-11T16:30:00Z" + } + ] + } + + @staticmethod + def parse_datetime(iso_str): + """Konversi timestamp API ke string datetime UTC yang diterima Odoo. + + API dapat mengirim ISO string maupun objek ``datetime``. Keduanya + dinormalisasi ke format yang sama agar tidak terjadi perbandingan + ``str`` dengan ``datetime`` saat audit trail direkonstruksi. + """ + if not iso_str: + return False + try: + if isinstance(iso_str, datetime.datetime): + dt = iso_str + elif isinstance(iso_str, str): + clean = iso_str.strip().replace("Z", "+00:00") + dt = datetime.datetime.fromisoformat(clean) + else: + _logger.warning("Tipe datetime tidak didukung: %r", type(iso_str)) + return False + + # Konversi ke naive UTC untuk Odoo. + if dt.tzinfo is not None: + dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception as e: + _logger.warning("Gagal parse datetime '%s': %s", iso_str, e) + return False diff --git a/gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml new file mode 100644 index 0000000..16f737c --- /dev/null +++ b/gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml @@ -0,0 +1,134 @@ + + + + + freshservice.holiday.list + freshservice.holiday + + +
+
+ + + + + + +
+
+
+ + + + freshservice.holiday.form + freshservice.holiday + +
+
+
+ +
+
+ + + + + + + + + + + +
+
+
+
+ + + + freshservice.holiday.search + freshservice.holiday + + + + + + + + + + + + + + + + + + + + + + + + Hari Libur & Cuti Bersama + freshservice.holiday + list,form + + {'search_default_filter_active': 1, 'search_default_group_by_year': 1} + +

+ Belum ada data hari libur atau cuti bersama yang terdaftar. +

+

+ Klik tombol Muat Libur Baku (2024-2026) untuk langsung memuat data hari libur nasional Indonesia, + atau klik Impor Excel untuk mengunggah jadwal hari libur custom perusahaan Anda. +

+
+
+
diff --git a/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml new file mode 100644 index 0000000..69b42b0 --- /dev/null +++ b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml @@ -0,0 +1,119 @@ + + + + + freshservice.ticket.stage.log.tree + freshservice.ticket.stage.log + + + + + + + + + + + + + + + + + + + + freshservice.ticket.stage.log.form + freshservice.ticket.stage.log + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + freshservice.ticket.stage.log.search + freshservice.ticket.stage.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Audit Trail Riwayat Status + freshservice.ticket.stage.log + list,form + + {'search_default_group_by_ticket': 1} + +
diff --git a/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml new file mode 100644 index 0000000..a4f5c8c --- /dev/null +++ b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml @@ -0,0 +1,337 @@ + + + + + freshservice.ticket.form + freshservice.ticket + +
+
+
+ + + +
+
+ + +
+
+
+ First Response +

+ Oleh: +
+
+
+
+ Agent +

+ Waiting on Us +
+
+
+
+ User +

+ Waiting on Customer +
+
+
+
+ Scheduled Meeting +

+ Waiting Together +
+
+
+
+ Third Party +

+ Waiting on Vendor +
+
+
+
+ Waktu Selesai +

+ Total Resolution +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + freshservice.ticket.tree + freshservice.ticket + + +
+
+ + + + + + + + + + + + + + +
+
+
+ + + + freshservice.ticket.kanban + freshservice.ticket + + + + + + + + + + + + +
+
+ # + +
+
+ +
+
+ Agen: + +
+
+ Agen: + User: +
+
+
+
+
+
+
+ + + + freshservice.ticket.pivot + freshservice.ticket + + + + + + + + + + + + + + + freshservice.ticket.graph + freshservice.ticket + + + + + + + + + + + + freshservice.ticket.search + freshservice.ticket + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Daftar Tiket + freshservice.ticket + list,form,kanban,pivot,graph + + {'search_default_filter_open_progress': 0} + 200 + +

+ Belum ada tiket Freshservice yang dimuat! +

+

+ Klik tombol "Tarik Tiket Terbaru Freshservice" pada header tabel untuk menarik daftar tiket dari Freshservice secara otomatis. +

+
+
+
diff --git a/gmn_freshservice/views/gmn_menu_views_v01.xml b/gmn_freshservice/views/gmn_menu_views_v01.xml new file mode 100644 index 0000000..3493c4e --- /dev/null +++ b/gmn_freshservice/views/gmn_menu_views_v01.xml @@ -0,0 +1,65 @@ + + + + + + + + Buka & Auto-Fetch Tiket Freshservice + + code + +action = model.action_open_and_fetch_tickets() + + + + + + + + + + + + + + + + + + + diff --git a/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml b/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml new file mode 100644 index 0000000..c8d7604 --- /dev/null +++ b/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml @@ -0,0 +1,46 @@ + + + + res.config.settings.view.form.freshservice + res.config.settings + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + Pengaturan Freshservice + res.config.settings + form + main + {'module': 'gmn_freshservice'} + +
diff --git a/gmn_freshservice/wizard/__init__.py b/gmn_freshservice/wizard/__init__.py new file mode 100644 index 0000000..fc92186 --- /dev/null +++ b/gmn_freshservice/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +"""Package wizard untuk laporan bottleneck tiket dan utilitas impor Freshservice.""" +from . import gmn_freshservice_ticket_bottleneck_wizard_v01 +from . import gmn_freshservice_holiday_import_wizard_v01 diff --git a/gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8a9a794 Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc new file mode 100644 index 0000000..541d3ce Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/__pycache__/gmn_freshservice_holiday_import_wizard_v01.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_holiday_import_wizard_v01.cpython-312.pyc new file mode 100644 index 0000000..170fb4b Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_holiday_import_wizard_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc new file mode 100644 index 0000000..2c441de Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_v01.py b/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_v01.py new file mode 100644 index 0000000..a9a7f9b --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_v01.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +"""Wizard Impor Hari Libur Nasional & Cuti Bersama dari File Excel (.xlsx). +Membaca file spreadsheet Excel yang diunggah pengguna, memvalidasi data tanggal, +dan memasukkan data hari libur ke dalam model freshservice.holiday. +""" +import io +import base64 +import logging +from datetime import datetime, date +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import openpyxl + +_logger = logging.getLogger(__name__) + + +class FreshserviceHolidayImportWizard(models.TransientModel): + """Wizard interaktif untuk mengunggah dan mengimpor data hari libur dari file Excel.""" + _name = 'freshservice.holiday.import.wizard' + _description = 'Wizard Impor Hari Libur dari Excel' + + file = fields.Binary(string='File Excel (.xlsx)', help='Pilih file Excel berformat .xlsx') + filename = fields.Char(string='Nama File') + import_mode = fields.Selection([ + ('append', 'Tambahkan / Perbarui (Upsert Tanggal)'), + ('replace', 'Ganti Seluruh Data (Hapus Data Lama)'), + ], string='Mode Impor', default='append', required=True, + help='Append: jika tanggal sudah ada maka datanya diperbarui, jika belum ada maka ditambahkan. ' + 'Replace: menghapus semua hari libur yang ada saat ini dan menggantinya dengan data dari file.') + + def action_download_template(self, *args, **kwargs): + """Mengunduh file template Excel resmi untuk pengisian hari libur.""" + return self.env['freshservice.holiday'].action_download_template(*args, **kwargs) + + @staticmethod + def _parse_excel_date(val): + """Mengonversi nilai tanggal dari sel Excel menjadi objek datetime.date.""" + if not val: + return None + if isinstance(val, datetime): + return val.date() + if isinstance(val, date): + return val + if isinstance(val, (int, float)): + # Excel serial number untuk rentang tahun 2000-2099 berada di antara 36526 dan 73050 + if 30000 <= val <= 90000: + try: + dt = openpyxl.utils.datetime.from_excel(val) + return dt.date() if isinstance(dt, datetime) else dt + except Exception: + return None + return None + if isinstance(val, str): + clean_str = val.strip() + for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%d-%m-%Y', '%Y/%m/%d', '%d.%m.%Y'): + try: + return datetime.strptime(clean_str, fmt).date() + except ValueError: + pass + return None + + def action_import_excel(self, *args, **kwargs): + """Memproses file Excel yang diunggah dan menyimpannya ke master data.""" + self.ensure_one() + if not self.file: + raise UserError(_('Silakan pilih file Excel (.xlsx) terlebih dahulu sebelum memproses impor.')) + + try: + file_bytes = base64.b64decode(self.file) + workbook = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + except Exception as e: + _logger.exception("Gagal membaca file Excel hari libur: %s", e) + raise UserError(_('File yang diunggah bukan file spreadsheet Excel yang valid (.xlsx). Pesan error: %s') % str(e)) + + sheet = workbook.active + if sheet is None: + raise UserError(_('File Excel tidak memiliki lembar kerja (worksheet) yang aktif.')) + + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + raise UserError(_('File Excel kosong atau tidak memiliki baris data.')) + + # Deteksi baris header dan pemetaan indeks kolom secara cerdas + header_row_idx = None + date_col = None + name_col = None + type_col = None + notes_col = None + + for idx, row in enumerate(rows): + row_str = [str(c).lower().strip() if c is not None else '' for c in row] + # Lewati baris banner / petunjuk teks panjang + if any('petunjuk' in c or 'template import' in c for c in row_str): + continue + + found_date = None + found_name = None + found_type = None + found_notes = None + + for col_i, cell_text in enumerate(row_str): + if not cell_text: + continue + if ('tanggal' in cell_text or 'date' in cell_text) and found_date is None: + found_date = col_i + elif ('nama' in cell_text or 'deskripsi' in cell_text) and found_name is None: + found_name = col_i + elif ('jenis' in cell_text or 'kategori' in cell_text or 'type' in cell_text) and found_type is None: + found_type = col_i + elif ('keterangan' in cell_text or 'catatan' in cell_text or 'notes' in cell_text) and found_notes is None: + found_notes = col_i + + # Baris header yang sah harus mengandung kolom tanggal dan nama + if found_date is not None and found_name is not None: + header_row_idx = idx + date_col = found_date + name_col = found_name + type_col = found_type + notes_col = found_notes + break + + # Fallback indeks kolom jika tidak terdeteksi dari teks header + if header_row_idx is None or date_col is None: + header_row_idx = 0 + if len(rows[0]) >= 5: + date_col, name_col, type_col, notes_col = 1, 2, 3, 4 + elif len(rows[0]) >= 4: + date_col, name_col, type_col, notes_col = 0, 1, 2, 3 + else: + date_col, name_col, type_col, notes_col = 0, 1, None, None + + if name_col is None: + name_col = date_col + 1 + + Holiday = self.env['freshservice.holiday'] + + # Jika mode 'replace', hapus data yang ada sebelumnya + if self.import_mode == 'replace': + Holiday.search([]).unlink() + + created_count = 0 + updated_count = 0 + skipped_count = 0 + + # Iterasi data mulai dari baris setelah header + for r_idx in range(header_row_idx + 1, len(rows)): + row = rows[r_idx] + if not row or all(c is None or str(c).strip() == '' for c in row): + continue + + # Parsing tanggal + raw_date = row[date_col] if date_col < len(row) else None + parsed_date = self._parse_excel_date(raw_date) + if not parsed_date: + skipped_count += 1 + continue + + # Parsing nama + raw_name = row[name_col] if name_col < len(row) else None + holiday_name = str(raw_name).strip() if raw_name else _('Hari Libur') + + # Parsing jenis libur + holiday_type = 'public' + if type_col is not None and type_col < len(row) and row[type_col]: + type_str = str(row[type_col]).lower() + if 'cuti' in type_str or 'joint' in type_str: + holiday_type = 'joint_leave' + elif 'khusus' in type_str or 'perusahaan' in type_str or 'company' in type_str: + holiday_type = 'company' + + # Parsing catatan + notes_str = '' + if notes_col is not None and notes_col < len(row) and row[notes_col]: + notes_str = str(row[notes_col]).strip() + + # Simpan / perbarui ke database (upsert) + existing = Holiday.search([('date', '=', parsed_date)], limit=1) + vals = { + 'date': parsed_date, + 'name': holiday_name, + 'holiday_type': holiday_type, + 'notes': notes_str, + 'active': True, + } + if existing: + existing.write(vals) + updated_count += 1 + else: + Holiday.create(vals) + created_count += 1 + + total_processed = created_count + updated_count + if total_processed == 0: + raise UserError(_('Tidak ada data tanggal libur valid yang dapat diimpor dari file Excel. Pastikan kolom tanggal terisi format YYYY-MM-DD.')) + + msg = _('Impor berhasil! Ditambahkan: %d, Diperbarui: %d.') % (created_count, updated_count) + if skipped_count > 0: + msg += _(' (%d baris dilewati karena format tanggal tidak sesuai/kosong).') % skipped_count + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Impor Excel Selesai'), + 'message': msg, + 'type': 'success', + 'sticky': False, + 'next': { + 'type': 'ir.actions.act_window', + 'res_model': 'freshservice.holiday', + 'view_mode': 'list,form', + 'views': [[False, 'list'], [False, 'form']], + 'target': 'main', + }, + } + } diff --git a/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml b/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml new file mode 100644 index 0000000..79c23d4 --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml @@ -0,0 +1,56 @@ + + + + + freshservice.holiday.import.wizard.form + freshservice.holiday.import.wizard + +
+
+

+ Petunjuk Impor Spreadsheet Excel: +

+
    +
  • Gunakan file berformat .xlsx (Microsoft Excel / Google Sheets).
  • +
  • Pastikan kolom Tanggal Libur terisi dengan format standar YYYY-MM-DD (contoh: 2026-08-17).
  • +
  • Kolom Jenis Libur dapat diisi: Hari Libur Nasional, Cuti Bersama, atau Libur Khusus Perusahaan.
  • +
+
+
+
+ + + + + + + + + +
+
+ +
+
+ + + + Impor Hari Libur dari Excel + freshservice.holiday.import.wizard + form + new + +
diff --git a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py new file mode 100644 index 0000000..d515d03 --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py @@ -0,0 +1,1077 @@ +# -*- coding: utf-8 -*- +"""Wizard untuk generate laporan Excel analisis bottleneck tiket Freshservice. +Bisa bikin 2 jenis laporan: +1. Detail bottleneck satu tiket (breakdown per stage siapa yang lama) +2. Ranking multi-tiket (perbandingan banyak tiket sekaligus) +""" +import io +import re +import base64 +import logging +from datetime import datetime +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import xlsxwriter +from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days, compute_working_hours, format_working_duration + +_logger = logging.getLogger(__name__) + + +class FreshserviceTicketBottleneckWizard(models.TransientModel): + """Wizard popup untuk bikin laporan Excel bottleneck. + User pilih tiket, filter tanggal/status, lalu klik Generate. + Hasilnya file Excel yang bisa langsung diunduh. + """ + _name = 'freshservice.ticket.bottleneck.wizard' + _description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)' + + report_type = fields.Selection([ + ('single', 'Analisis Detail Bottleneck Tiket Tunggal (cth: #992)'), + ('ranking', 'Ranking Bottleneck Multi-Tiket (Bahan Meeting)'), + ], string='Tipe Laporan', default='single', required=True) + + ticket_id = fields.Many2one( + 'freshservice.ticket', + string='Pilih Tiket Odoo', + help='Pilih tiket yang sudah ada di database Odoo' + ) + + external_ticket_id = fields.Char( + string='Nomor ID Tiket Freshservice', + default='992', + help='Masukkan nomor ID Freshservice (misal: 992) untuk langsung mencari atau menarik data tiket tersebut.' + ) + + ticket_ids = fields.Many2many( + 'freshservice.ticket', + 'freshservice_wizard_ticket_rel', + 'wizard_id', + 'ticket_id', + string='Pilih Daftar Tiket Spesifik (Multi-Tiket)', + help='Kosongkan jika ingin mengambil tiket berdasarkan filter tanggal/status di bawah.' + ) + + date_from = fields.Date(string='Dari Tanggal Dibuat') + date_to = fields.Date(string='Sampai Tanggal Dibuat') + as_of = fields.Datetime( + string='Batas Waktu Cut-Off (As-Of)', + help='Batas waktu simulasi retrospektif (opsional). ' + 'Jika diisi, perhitungan durasi seluruh stage log dipotong sampai timestamp ini. ' + 'Kosongkan untuk perhitungan real-time hingga saat ini.', + ) + + status_filter = fields.Selection([ + ('all', 'Semua Status'), + ('open', 'Open'), + ('pending', 'Pending User'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ], string='Filter Status', default='all') + + auto_sync_detail = fields.Boolean( + string='Auto-Fetch Detail Freshservice', + default=True, + help='Jika dicentang, wizard akan otomatis mengambil rincian stage log dan percakapan terbaru dari Freshservice bila belum tersinkron.' + ) + + state = fields.Selection([ + ('choose', 'Pilih Parameter'), + ('get', 'Laporan Siap Diunduh'), + ], string='Status', default='choose') + + excel_file = fields.Binary(string='File Excel Laporan', readonly=True) + file_name = fields.Char(string='Nama File', readonly=True) + bottleneck_summary_html = fields.Html(string='Ringkasan Analisis Bottleneck', readonly=True) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get('active_model') + active_id = self.env.context.get('active_id') + if active_model == 'freshservice.ticket' and active_id: + ticket = self.env['freshservice.ticket'].browse(active_id) + if ticket.exists(): + res['ticket_id'] = ticket.id + if ticket.external_ticket_id: + res['external_ticket_id'] = ticket.external_ticket_id + return res + + @api.onchange('ticket_id') + def _onchange_ticket_id(self): + """Kalau user pilih tiket dari dropdown, otomatis isi nomor ID Freshservice-nya.""" + if self.ticket_id and self.ticket_id.external_ticket_id: + self.external_ticket_id = self.ticket_id.external_ticket_id + + def _strip_html(self, text): + """Menghapus tag HTML agar teks di Excel bersih.""" + if not text: + return "" + clean = re.sub(r'<[^>]+>', ' ', text) + clean = re.sub(r'\s+', ' ', clean).strip() + return clean + + def _effective_responsibility(self, log): + """Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review, + kalau belum pakai hasil deteksi otomatis. + """ + return log.override_responsibility or log.responsibility + + def _clipped_stage_interval(self, log, report_as_of): + """Potong interval stage log sesuai cut-off laporan. + Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu. + """ + start = log.start_date + if not start: + return False, False + if report_as_of and start >= report_as_of: + return False, False + end = log.end_date or report_as_of + if report_as_of and (not end or end > report_as_of): + end = report_as_of + if not end or end <= start: + return False, False + return start, end + + def _ensure_ticket_details(self, ticket): + """Selalu sync ulang detail tiket dari Freshservice setiap kali export.""" + if self.auto_sync_detail: + try: + ticket.action_sync_from_freshservice() + except Exception as e: + _logger.warning("Gagal auto-sync detail untuk tiket #%s: %s", ticket.external_ticket_id, str(e)) + + def action_generate_excel(self): + """Generate laporan analisis bottleneck dalam format Excel (.xlsx).""" + self.ensure_one() + + tickets = self.env['freshservice.ticket'] + if self.report_type == 'single': + # 1. Cari atau buat tiket berdasarkan ticket_id atau external_ticket_id + target_ticket = self.ticket_id + if not target_ticket and self.external_ticket_id: + clean_ext_id = self.external_ticket_id.strip() + target_ticket = self.env['freshservice.ticket'].search([('external_ticket_id', '=', clean_ext_id)], limit=1) + if not target_ticket: + # Buat record baru jika belum ada di Odoo, lalu sinkronkan + target_ticket = self.env['freshservice.ticket'].create({ + 'name': f'Tiket Freshservice #{clean_ext_id}', + 'external_ticket_id': clean_ext_id, + }) + try: + target_ticket.action_sync_from_freshservice() + except Exception as e: + raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e))) + else : + try: + target_ticket.action_sync_from_freshservice() + except Exception as e: + raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e))) + + if not target_ticket: + raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992).")) + + self._ensure_ticket_details(target_ticket) + tickets = target_ticket + report_name = f"Report_Tiket_{target_ticket.external_ticket_id}_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx" + else: + # Mode Multi-Tiket / Ranking + if self.ticket_ids: + tickets = self.ticket_ids + else: + domain = [] + if self.date_from: + domain.append(('ticket_created_at', '>=', fields.Datetime.to_datetime(self.date_from))) + if self.date_to: + domain.append(('ticket_created_at', '<=', fields.Datetime.to_datetime(self.date_to).replace(hour=23, minute=59, second=59))) + if self.status_filter and self.status_filter != 'all': + domain.append(('status', '=', self.status_filter)) + tickets = self.env['freshservice.ticket'].search(domain, order='resolution_time_days desc, id desc') + + if not tickets: + raise UserError(_("Tidak ada tiket yang ditemukan dengan kriteria filter tersebut.")) + + for t in tickets[:50]: # Batasi auto-sync rincian untuk performa optimal + if self.auto_sync_detail: + try: + t.action_sync_from_freshservice() + except Exception: + pass + report_name = f"Ranking_Bottleneck_Tiket_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx" + + # Generate file Excel menggunakan xlsxwriter + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True, 'remove_timezone': True}) + + # ========================================== + # FORMAT STYLING EXCEL + # ========================================== + title_fmt = workbook.add_format({ + 'bold': True, 'font_size': 15, 'font_name': 'Calibri', + 'font_color': '#FFFFFF', 'bg_color': '#1B365D', + 'align': 'left', 'valign': 'vcenter' + }) + subtitle_fmt = workbook.add_format({ + 'italic': True, 'font_size': 9, 'font_name': 'Calibri', + 'font_color': '#5D6D7E', 'align': 'left' + }) + section_fmt = workbook.add_format({ + 'bold': True, 'font_size': 11, 'font_name': 'Calibri', + 'font_color': '#1B365D', 'bg_color': '#EAEDED', + 'bottom': 1, 'top': 1, 'border_color': '#BDC3C7' + }) + th_fmt = workbook.add_format({ + 'bold': True, 'font_size': 10, 'font_name': 'Calibri', + 'font_color': '#FFFFFF', 'bg_color': '#2E4053', + 'align': 'center', 'valign': 'vcenter', 'border': 1, 'border_color': '#BDC3C7' + }) + td_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'border': 1, + 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_wrap_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'border': 1, + 'border_color': '#D5D8DC', 'valign': 'top', 'text_wrap': True + }) + td_center_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'center', + 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_num_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'right', + 'num_format': '#,##0', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_dec_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'right', + 'num_format': '#,##0.00', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_pct_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'right', + 'num_format': '0.0%', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + td_date_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'align': 'center', + 'num_format': 'yyyy-mm-dd hh:mm', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter' + }) + + # Format Khusus Highlight Bottleneck + bn_badge_fmt = workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, + 'border_color': '#E6B0AA', 'align': 'center', 'valign': 'vcenter' + }) + normal_badge_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, + 'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter' + }) + scheduled_meeting_fmt = workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, + 'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter' + }) + kpi_box_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1' + }) + kpi_box_val = workbook.add_format({ + 'font_size': 12, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FFFFFF', 'font_color': '#154360', 'border': 1, 'border_color': '#AED6F1', + 'align': 'center', 'valign': 'vcenter' + }) + + # Format Badge Status Tiket + status_formats = { + 'open': workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#FADBD8', 'font_color': '#C0392B', 'border': 1, + 'border_color': '#E6B0AA', 'align': 'center', 'valign': 'vcenter' + }), # Merah + 'pending': workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#FCF3CF', 'font_color': '#B7950B', 'border': 1, + 'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter' + }), # Kuning + 'resolved': workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#D4EFDF', 'font_color': '#1E8449', 'border': 1, + 'border_color': '#A9DFBF', 'align': 'center', 'valign': 'vcenter' + }), # Hijau + 'closed': workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#E5E7E9', 'font_color': '#5D6D7E', 'border': 1, + 'border_color': '#BDC3C7', 'align': 'center', 'valign': 'vcenter' + }), # Abu-abu + } + + # Format abu-abu untuk status SLA Paused (On Hold / Third Party / Internal Discussion) + sla_paused_fmt = workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#E5E7E9', 'font_color': '#5D6D7E', 'border': 1, + 'border_color': '#BDC3C7', 'align': 'center', 'valign': 'vcenter' + }) + + summary_html = "" + + if self.report_type == 'single': + t = tickets[0] + summary_html = self._build_single_ticket_sheets( + workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, + td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt + ) + else: + summary_html = self._build_ranking_sheets( + workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt, + td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt + ) + + workbook.close() + output.seek(0) + excel_bytes = output.read() + output.close() + + excel_b64 = base64.b64encode(excel_bytes) + self.write({ + 'excel_file': excel_b64, + 'file_name': report_name, + }) + + # Return action download langsung agar browser segera mendownload file Excel + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/?model={self._name}&id={self.id}&field=excel_file&filename={report_name}&download=true', + 'target': 'self', + } + + def _get_stage_conversation_body(self, log, ticket): + """Mendapatkan isi pesan atau balasan untuk stage log tertentu. + Mencari dari log.activity_content atau fallback mencocokkan waktu dengan ticket.conversation_ids. + """ + raw_act = log.activity_content or '' + # Jika sudah ada "Isi Pesan:" yang disematkan saat sync + if 'Isi Pesan:' in raw_act: + parts = raw_act.split('Isi Pesan:') + if len(parts) > 1: + return parts[1].strip().strip('"').strip("'") + + # Fallback: cari di conversation_ids dengan timestamp yang sama atau berdekatan (< 5 detik) + if log.start_date and ticket.conversation_ids: + for conv in ticket.conversation_ids: + if conv.created_at: + diff = abs((conv.created_at - log.start_date).total_seconds()) + if diff <= 5: + body = conv.body_text or '' + if body.strip(): + return body.strip() + + # Jika aksi adalah reply/note tapi tidak ada pesan terpisah, cek apakah di activity_content ada teks aksi + return '' + + def _build_single_ticket_sheets( + self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, + td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt + ): + """Membuat sheet untuk analisis mendalam bottleneck tiket tunggal.""" + ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI') + ws1.set_column('A:A', 5) + ws1.set_column('B:B', 26) + ws1.set_column('C:C', 30) + ws1.set_column('D:D', 26) + ws1.set_column('E:E', 26) + ws1.set_column('F:F', 22) + ws1.set_column('G:G', 16) + ws1.set_column('H:H', 16) + ws1.set_column('I:I', 14) + + # Header Title + ws1.merge_range('B2:H2', f'TICKET ANALYSIS REPORT #{t.external_ticket_id}', title_fmt) + cutoff_info = "" + if self.as_of: + from datetime import timedelta as _td + as_of_wib = (self.as_of + _td(hours=7)).strftime('%d-%m-%Y %H:%M') + cutoff_info = f" | Cut-off As-Of: {as_of_wib} WIB" + ws1.write( + 'B3', + f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}{cutoff_info}', + subtitle_fmt + ) + + # Info Header Tiket + ws1.merge_range('B5:H5', 'Ticket Information', section_fmt) + + ws1.write('B6', 'ID Freshservice', td_fmt) + ws1.write('C6', f'#{t.external_ticket_id}', td_fmt) + ws1.write('D6', 'Current Status', td_fmt) + st_fmt = status_formats.get(t.status, td_center_fmt) + ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt) + + ws1.write('B7', 'Judul Tiket', td_fmt) + ws1.merge_range('B7:E7', t.name or '-', td_fmt) + + ws1.write('B8', 'Pemohon (User)', td_fmt) + ws1.write('C8', f"{t.requester_name or '-'} ({t.requester_email or '-'})", td_fmt) + ws1.write('D8', 'Departemen', td_fmt) + ws1.write('E8', t.department_name or '-', td_fmt) + + ws1.write('B9', 'Agent IT Penanggung Jawab', td_fmt) + ws1.write('C9', t.responder_name or '-', td_fmt) + ws1.write('D9', 'Prioritas', td_fmt) + ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt) + + ws1.write('B10', 'Created At', td_fmt) + ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt) + ws1.write('D10', 'Resolved At', td_fmt) + ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt) + + # KPI BOXES + ws1.write('B12', 'METRIK DURASI & TANGGUNG JAWAB', section_fmt) + ws1.merge_range('B12:H12', 'METRIK DURASI & TANGGUNG JAWAB', section_fmt) + + # --- Baris 13-14: Metrik Hari Kalender (Lama) --- + # kpi_cal_label = workbook.add_format({ + # 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + # 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1' + # }) + # ws1.write('B13', '📅 First Response (Hari Kalender)', kpi_cal_label) + # ws1.write('C13', '📅 Total Waktu (Hari Kalender)', kpi_cal_label) + # ws1.write('D13', '📅 Waiting on Us / IT (Hari Kalender)', kpi_cal_label) + # ws1.write('E13', '📅 Waiting on User (Hari Kalender)', kpi_cal_label) + # ws1.write('F13', 'Status Tanggung Jawab', kpi_cal_label) + + # Recalculate duration on-the-fly agar data lama langsung menggunakan logika hari kerja tanpa perlu re-sync + calc_fr_days = compute_calendar_days(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0 + calc_res_days = compute_calendar_days(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0 + + stage_days_map = {} + calc_agent_days = 0 + calc_cust_days = 0 + for s in t.stage_log_ids: + s_days = compute_calendar_days(self.env, s.start_date, s.end_date) + stage_days_map[s.id] = s_days + if s.responsibility == 'internal': + calc_agent_days += s_days + elif s.responsibility == 'customer': + calc_cust_days += s_days + + # ws1.write('B14', f"{calc_fr_days} Hari Kalender ({t.first_responder_name or '-'})", kpi_box_val) + # ws1.write('C14', f"{calc_res_days} Hari Kalender", kpi_box_val) + # ws1.write('D14', f"{calc_agent_days} Hari Kalender", kpi_box_val) + # ws1.write('E14', f"{calc_cust_days} Hari Kalender", kpi_box_val) + # ws1.write('F14', dict(t._fields['current_responsibility'].selection).get(t.current_responsibility, '-'), kpi_box_val) + + # --- Baris 15-16: Metrik Jam Kerja Operasional (Baru, UTC+7) --- + kpi_wh_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#A9DFBF' + }) + kpi_wh_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF', + 'align': 'center', 'valign': 'vcenter' + }) + # Warna KPI mengikuti responsibility: internal merah, customer orange, + # scheduled meeting biru, dan third party abu-abu. + kpi_internal_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA' + }) + kpi_customer_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F' + }) + kpi_meeting_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9' + }) + kpi_third_party_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7' + }) + kpi_internal_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_customer_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_meeting_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_third_party_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7', + 'align': 'center', 'valign': 'vcenter' + }) + ws1.write('B13', 'First Response', kpi_wh_label) + ws1.write('C13', 'Total Waktu', kpi_wh_label) + ws1.write('D13', 'Agent Responsibility', kpi_internal_label) + ws1.write('E13', 'Requester Responsibility', kpi_customer_label) + ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label) + ws1.write('G13', 'Third Party Responsibility', kpi_third_party_label) + ws1.write('H13', 'Catatan Rumus', kpi_wh_label) + + # Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat) + wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0 + wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0 + + report_as_of = self.as_of or fields.Datetime.now() + wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0 + wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 0.0 + stage_wh_map = {} + stage_resp_map = {} + for s in t.stage_log_ids: + start, end = self._clipped_stage_interval(s, report_as_of) + if not start: + stage_wh_map[s.id] = 0.0 + continue + s_wh = compute_working_hours(self.env, start, end) + resp = self._effective_responsibility(s) + stage_wh_map[s.id] = s_wh + stage_resp_map[s.id] = resp + if resp == 'internal': + wh_it_direct += s_wh + elif resp in ('it_hold', 'on_hold', 'internal_discussion'): + wh_it_hold += s_wh + elif resp == 'customer': + wh_cust += s_wh + elif resp == 'user_after_auto_resolve': + wh_user_auto += s_wh + elif resp == 'needs_verification': + wh_needs_verif += s_wh + elif resp == 'scheduled_meeting': + wh_meeting += s_wh + elif resp in ('third_party', 'external_activity'): + wh_vendor += s_wh + elif resp == 'resolved_idle': + wh_resolved_idle += s_wh + + wh_agent = wh_it_direct + wh_it_hold + wh_requester = wh_cust + wh_user_auto + wh_needs_verif + wh_total = sum(stage_wh_map.values()) + ws1.write('B14', format_working_duration(wh_fr), kpi_box_val) + ws1.write('C14', format_working_duration(wh_total), kpi_box_val) + ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val) + ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val) + ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val) + ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val) + ws1.write('H14', 'Monday-Friday, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 hours/day)\nDB UTC → +7 WIB', kpi_box_val) + + # RESPONSIBILITY RECAP — must match the Sheet 2 total. + ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt) + ws1.write('B17', 'Responsibility Bucket', th_fmt) + ws1.merge_range('C17:F17', 'Operational Description', th_fmt) + ws1.write('G17', 'Working Hours', th_fmt) + ws1.write('H17', 'Effective Workdays', th_fmt) + ws1.write('I17', '% of Load', th_fmt) + recap_rows = [ + ('Agent Active Work', 'Direct Agent handling while the ticket is Open or In Progress.', wh_it_direct), + ('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold), + ('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust), + ('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto), + ('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif), + ('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting), + ('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor), + ('Resolved / Idle Time', 'Resolved or closed time without a further attributable action.', wh_resolved_idle), + ] + # xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18. + # Ini menghindari satu baris kosong sebelum rekap pertama. + customer_recap_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF', + 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', + 'align': 'left', 'valign': 'vcenter' + }) + customer_recap_number_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF', + 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', + 'align': 'center', 'valign': 'vcenter', 'num_format': '0.00' + }) + customer_recap_pct_fmt = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF', + 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', + 'align': 'center', 'valign': 'vcenter', 'num_format': '0.00%' + }) + for recap_row, (label, description, hours) in enumerate(recap_rows, start=17): + is_customer = label.startswith('Waiting on Requester') or label.startswith('Waiting on User') + label_fmt = ( + bn_badge_fmt if label.startswith('Agent ') else + customer_recap_number_fmt if is_customer else + scheduled_meeting_fmt if label.startswith('Scheduled') else + sla_paused_fmt or td_center_fmt + ) + ws1.write(recap_row, 1, label, label_fmt) + ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt) + excel_row = recap_row + 1 + ws1.write_number(recap_row, 6, round(hours, 2), td_dec_fmt) + ws1.write_number(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt) + ws1.write_formula(recap_row, 8, f'=IF($G$26=0,0,G{excel_row}/$G$26)', td_pct_fmt) + + # Grand total: Excel formula rather than a static number. + ws1.write(25, 1, 'Grand Total Ticket Lifetime', section_fmt) + ws1.merge_range(25, 2, 25, 5, 'Total across all eight responsibility buckets.', section_fmt) + ws1.write_formula(25, 6, '=SUM(G18:G25)', td_dec_fmt) + ws1.write_formula(25, 7, '=SUM(H18:H25)', td_dec_fmt) + ws1.write_formula(25, 8, '=SUM(I18:I25)', td_pct_fmt) + + # Conservative IT workload range: the maximum includes pending user verification. + ws1.write('B27', 'Estimated Agent Workload Range', section_fmt) + ws1.write('C27', 'Minimum (Agent Active Work + Agent Hold)', td_fmt) + ws1.write_formula('D27', '=G18+G19', td_dec_fmt) + ws1.write('E27', 'Maximum (+ User Verification)', td_fmt) + ws1.write_formula('F27', '=D27+G22', td_dec_fmt) + + # Aggregate ranking by the party responsible for the next action. + ws1.merge_range('B29:E29', 'AGGREGATE BOTTLENECK RANKING BY PARTY', section_fmt) + aggregate_rows = [ + ('Agent', wh_it_direct + wh_it_hold, 'Active work and internal hold.'), + ('Requester', wh_cust + wh_user_auto + wh_needs_verif, 'Waiting on requester, auto-resolve & user verification.'), + ('Shared', wh_meeting, 'Waiting for a scheduled meeting.'), + ('Third Party', wh_vendor, 'Vendor or external party.') + ] + if wh_resolved_idle > 0: + aggregate_rows.append(('Resolved / Idle', wh_resolved_idle, 'Resolved or closed idle time.')) + for col_idx, header in enumerate(['Party', 'Working Hours', 'Workdays', '% of Load'], start=1): + ws1.write(29, col_idx, header, th_fmt) + for aggregate_row, (party, hours, note) in enumerate(aggregate_rows, start=30): + ws1.write(aggregate_row, 1, party, td_fmt) + ws1.write_number(aggregate_row, 2, round(hours, 2), td_dec_fmt) + ws1.write_number(aggregate_row, 3, round(hours / 8.0, 2), td_dec_fmt) + ws1.write_formula(aggregate_row, 4, f'=IF($G$26=0,0,C{aggregate_row + 1}/$G$26)', td_pct_fmt) + + # TABEL RANKING BOTTLENECK PER STAGE + ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt) + headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status'] + for col_idx, h in enumerate(headers_bn, start=1): + ws1.write(36, col_idx, h, th_fmt) + + sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True) + total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01) + + row = 37 + for rank_idx, stage in enumerate(sorted_stages, start=1): + stage_wh = stage_wh_map.get(stage.id, 0.0) + stage_wd_eff = round(stage_wh / 8.0, 2) + resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage)) + if resp == 'internal': + resp_label = 'Agent Active Work' + resp_fmt = bn_badge_fmt + elif resp in ('it_hold', 'on_hold', 'internal_discussion'): + resp_label = 'Agent Hold & Discussion' + resp_fmt = bn_badge_fmt + elif resp == 'customer': + resp_label = 'Waiting on Requester' + resp_fmt = normal_badge_fmt + elif resp == 'user_after_auto_resolve': + resp_label = 'Waiting on User After Auto-Resolve' + resp_fmt = normal_badge_fmt + elif resp == 'needs_verification': + resp_label = 'Waiting on User Verification' + resp_fmt = normal_badge_fmt + elif resp == 'scheduled_meeting': + resp_label = 'Scheduled Meeting (Shared)' + resp_fmt = scheduled_meeting_fmt + elif resp in ('third_party', 'external_activity'): + resp_label = 'Waiting on Third Party' + resp_fmt = sla_paused_fmt or td_center_fmt + elif resp == 'resolved_idle': + resp_label = 'Resolved / Idle Time' + resp_fmt = td_center_fmt + else: + resp_label = 'Waiting on Requester' + resp_fmt = normal_badge_fmt + is_primary = (rank_idx == 1 and stage_wh > 0.0) + + ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt) + ws1.write(row, 2, stage.stage_name or '-', td_fmt) + ws1.write(row, 3, resp_label, resp_fmt) + ws1.write(row, 4, stage.actor_name or '-', td_fmt) + ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt) + ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt) + ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt) + ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt) + row += 1 + + # SHEET 2: AUDIT TRAIL & CHRONOLOGY + ws2 = workbook.add_worksheet('Audit Trail & Chronology') + ws2.set_column('A:A', 4) + ws2.set_column('B:B', 6) + ws2.set_column('C:D', 18) + ws2.set_column('E:F', 24) + ws2.set_column('G:G', 28) + ws2.set_column('H:H', 14) + ws2.set_column('I:I', 20) + ws2.set_column('J:K', 16) + ws2.set_column('L:L', 20) + ws2.set_column('M:M', 32) + ws2.set_column('N:N', 16) + ws2.set_column('O:O', 35) + ws2.set_column('P:P', 55) + + ws2.merge_range('B2:P2', f'FULL AUDIT TRAIL CHRONOLOGY — TICKET #{t.external_ticket_id}', title_fmt) + headers_ws2 = [ + 'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status', + 'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name', + 'Effective Workdays', 'Working Hours', 'Delay Cause', + 'Classification Basis', 'User Verification Required', + 'Activity Details', 'Reply / Message Content' + ] + for col_idx, h in enumerate(headers_ws2, start=1): + ws2.write(3, col_idx, h, th_fmt) + + from datetime import timedelta as _td + def _to_wib_str(dt): + if not dt: + return 'Active' + try: + return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M') + except Exception: + return str(dt) + + row2 = 4 + current_idx2 = 0 + for log in t.stage_log_ids.sorted('start_date'): + start, end = self._clipped_stage_interval(log, report_as_of) + if not start: + continue + current_idx2 += 1 + resp = stage_resp_map.get(log.id, self._effective_responsibility(log)) + if resp == 'internal': + resp_label = 'Agent Active Work' + resp_fmt = bn_badge_fmt + elif resp in ('it_hold', 'on_hold', 'internal_discussion'): + resp_label = 'Agent Hold & Discussion' + resp_fmt = bn_badge_fmt + elif resp == 'customer': + resp_label = 'Waiting on Requester' + resp_fmt = normal_badge_fmt + elif resp == 'user_after_auto_resolve': + resp_label = 'Waiting on User After Auto-Resolve' + resp_fmt = normal_badge_fmt + elif resp == 'needs_verification': + resp_label = 'Waiting on User Verification' + resp_fmt = normal_badge_fmt + elif resp == 'scheduled_meeting': + resp_label = 'Scheduled Meeting (Shared)' + resp_fmt = scheduled_meeting_fmt + elif resp in ('third_party', 'external_activity'): + resp_label = 'Waiting on Third Party' + resp_fmt = sla_paused_fmt or td_center_fmt + elif resp == 'resolved_idle': + resp_label = 'Resolved / Idle Time' + resp_fmt = td_center_fmt + else: + resp_label = 'Waiting on Requester' + resp_fmt = normal_badge_fmt + + msg_body = self._get_stage_conversation_body(log, t) + raw_act_main = log.activity_content or '' + if 'Isi Pesan:' in raw_act_main: + raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip() + + wh = stage_wh_map.get(log.id, 0.0) + wd_eff = round(wh / 8.0, 2) + cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None') + verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No' + + effective_end = end if (self.as_of and end) else log.end_date + ws2.write(row2, 1, current_idx2, td_center_fmt) + ws2.write(row2, 2, _to_wib_str(start), td_center_fmt) + ws2.write(row2, 3, _to_wib_str(effective_end), td_center_fmt) + ws2.write(row2, 4, log.stage_name or '-', td_fmt) + ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt) + ws2.write(row2, 6, resp_label, resp_fmt) + ws2.write(row2, 7, log.actor_type or '-', td_center_fmt) + ws2.write(row2, 8, log.actor_name or '-', td_fmt) + ws2.write_number(row2, 9, wd_eff, td_dec_fmt) + ws2.write_number(row2, 10, round(wh, 2), td_dec_fmt) + ws2.write(row2, 11, cause_label, td_center_fmt) + ws2.write(row2, 12, log.classification_basis or '-', td_fmt) + ws2.write(row2, 13, verif_str, td_center_fmt) + ws2.write(row2, 14, self._strip_html(raw_act_main) or '-', td_fmt) + ws2.write(row2, 15, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt) + row2 += 1 + + # Grand total using Excel formulas + ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt) + ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt) + ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', td_dec_fmt) + ws2.merge_range(row2, 11, row2, 15, 'Matches Sheet 1: all stage logs calculated using effective business hours.', td_fmt) + + # SHEET 3: CONVERSATIONS & NOTES + ws3 = workbook.add_worksheet('Conversations & Notes') + ws3.set_column('A:A', 5) + ws3.set_column('B:B', 8) + ws3.set_column('C:C', 20) + ws3.set_column('D:D', 22) + ws3.set_column('E:E', 24) + ws3.set_column('F:F', 55) + + ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt) + headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content'] + for col_idx, h in enumerate(headers_ws3, start=1): + ws3.write(3, col_idx, h, th_fmt) + + row3 = 4 + current_idx3 = 0 + for conv in t.conversation_ids.sorted('created_at'): + if report_as_of and conv.created_at and conv.created_at > report_as_of: + continue + current_idx3 += 1 + sender_type_label = 'Requester (User)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent (IT)' + conv_wib_str = _to_wib_str(conv.created_at) + ws3.write(row3, 1, current_idx3, td_center_fmt) + ws3.write(row3, 2, conv_wib_str, td_center_fmt) + ws3.write(row3, 3, sender_type_label, normal_badge_fmt if conv.incoming else bn_badge_fmt) + ws3.write(row3, 4, conv.user_name or '-', td_fmt) + ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt) + row3 += 1 + + # HTML summary for wizard pop-up dialog + html_out = f""" +
+

📊 Ticket Bottleneck Analysis #{t.external_ticket_id}

+

Subject: {t.name or '-'}

+

Requester: {t.requester_name or '-'} | Department: {t.department_name or '-'} | Agent Agent: {t.responder_name or '-'}

+
+
+
+
+ TOTAL DURATION + {t.resolution_time_days} Days +
+
+
+
+ AGENT RESPONSIBILITY + {t.total_agent_time_days} Days +
+
+
+
+ REQUESTER RESPONSIBILITY + {t.total_customer_time_days} Days +
+
+
+
+ FIRST RESPONSE + {t.first_response_time_days} Days +
+
+
+ +
+ """ + return html_out + #
+ # 🚨 Temuan Bottleneck Utama:
+ # {primary_bn_text} + #
+ return html_out + + def _build_ranking_sheets( + self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt, + td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, + sla_paused_fmt=None, scheduled_meeting_fmt=None + ): + """Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim).""" + scheduled_meeting_fmt = scheduled_meeting_fmt or sla_paused_fmt or td_center_fmt + ws = workbook.add_worksheet('Ticket Bottleneck Ranking') + ws.set_column('A:A', 5) + ws.set_column('B:B', 8) + ws.set_column('C:C', 14) + ws.set_column('D:D', 30) + ws.set_column('E:E', 20) + ws.set_column('F:F', 18) + ws.set_column('G:G', 18) + ws.set_column('H:H', 12) + ws.set_column('I:I', 16) + ws.set_column('J:J', 16) + ws.set_column('K:K', 16) + ws.set_column('L:L', 16) + ws.set_column('M:M', 16) + ws.set_column('N:N', 26) + ws.set_column('O:O', 22) + ws.set_column('P:P', 16) + ws.set_column('Q:Q', 16) + + ws.merge_range('B2:Q2', 'BOTTLENECK RANKING & SLA EVALUATION REPORT', title_fmt) + ws.write( + 'B3', + f'Printed: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Printed by: {self.env.user.name}', + subtitle_fmt + ) + + headers = [ + 'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent', + 'Status', + 'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays', + 'Agent Time (Calendar Days)', 'Agent Time (Working Hours)', + 'User Time (Calendar Days)', 'User Time (Working Hours)', + 'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)' + ] + for col_idx, h in enumerate(headers, start=1): + ws.write(4, col_idx, h, th_fmt) + + # Sort tickets: urutkan dari durasi terlama (resolution_time_days desc atau total_agent_time_days desc) + sorted_tickets = tickets.sorted(key=lambda t: (t.resolution_time_days, t.total_agent_time_days), reverse=True) + + row = 5 + slowest_tickets = [] + for rank_idx, t in enumerate(sorted_tickets, start=1): + # Cari stage bottleneck terbesar di tiket ini + top_stage = t.stage_log_ids.sorted(key=lambda s: (s.duration_days, s.duration_hours), reverse=True) + top_stage_name = '-' + top_resp = '-' + top_days = 0 + top_wh = 0.0 + if top_stage: + top_stage_name = top_stage[0].stage_name or '-' + top_resp = 'Agent Active Work' if top_stage[0].responsibility == 'internal' else 'Waiting on Requester' + top_days = top_stage[0].duration_days + top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date) + + # Hitung jam kerja per tiket + t_wh_total = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0 + t_wh_agent = 0.0 + t_wh_cust = 0.0 + for s in t.stage_log_ids: + s_wh = compute_working_hours(self.env, s.start_date, s.end_date) + resp = s.override_responsibility or s.responsibility + if resp in ('internal', 'it_hold', 'on_hold', 'internal_discussion'): + t_wh_agent += s_wh + elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'): + t_wh_cust += s_wh + + t_wd_eff = round(t_wh_total / 8.0, 2) + + if rank_idx <= 5: + slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Days / {t_wh_total:.1f} Working Hours)") + + ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt) + ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt) + ws.write(row, 3, t.name or '-', td_fmt) + ws.write(row, 4, t.requester_name or '-', td_fmt) + ws.write(row, 5, t.department_name or '-', td_fmt) + ws.write(row, 6, t.responder_name or '-', td_fmt) + row_st_fmt = status_formats.get(t.status, td_center_fmt) + ws.write(row, 7, dict(t._fields['status'].selection).get(t.status, t.status or '-'), row_st_fmt) + ws.write(row, 8, t.resolution_time_days, td_num_fmt) + ws.write(row, 9, t_wh_total, td_dec_fmt) + ws.write(row, 10, t_wd_eff, td_dec_fmt) + ws.write(row, 11, t.total_agent_time_days, td_num_fmt) + ws.write(row, 12, t_wh_agent, td_dec_fmt) + ws.write(row, 13, t.total_customer_time_days, td_num_fmt) + ws.write(row, 14, t_wh_cust, td_dec_fmt) + ws.write(row, 15, top_stage_name, td_fmt) + ws.write(row, 16, top_days, td_num_fmt) + ws.write(row, 17, top_wh, td_dec_fmt) + row += 1 + + # SHEET 2: DETAIL STAGE LOG SEMUA TIKET + ws2 = workbook.add_worksheet('All Tickets Stage Logs') + ws2.set_column('A:A', 5) + ws2.set_column('B:B', 14) + ws2.set_column('C:C', 22) + ws2.set_column('D:D', 22) + ws2.set_column('E:E', 22) + ws2.set_column('F:F', 26) + ws2.set_column('G:G', 20) + ws2.set_column('H:H', 14) + ws2.set_column('I:I', 14) + ws2.set_column('J:J', 18) + ws2.set_column('K:K', 18) + ws2.set_column('L:L', 32) + ws2.set_column('M:M', 50) + + ws2.merge_range('B2:M2', 'ALL TICKET STAGE TRANSITIONS & DURATIONS', title_fmt) + headers2 = [ + 'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status', + 'Responsibility', 'Actor', + 'Calendar Days', 'Calendar Hours', + 'Working Hours (08:30-17:30)', 'Effective Workdays', + 'Activity Details', 'Reply / Message Content' + ] + for col_idx, h in enumerate(headers2, start=1): + ws2.write(3, col_idx, h, th_fmt) + + row2 = 4 + for t in sorted_tickets: + for log in t.stage_log_ids.sorted('start_date'): + if log.responsibility == 'internal': + resp_label = 'Agent Active Work' + elif log.responsibility == 'customer': + resp_label = 'Waiting on Requester' + elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'): + resp_label = 'Agent Hold & Discussion' + elif log.responsibility == 'third_party': + resp_label = 'Waiting on Third Party' + elif log.responsibility == 'scheduled_meeting': + resp_label = 'Scheduled Meeting (Shared)' + elif log.responsibility == 'user_after_auto_resolve': + resp_label = 'Waiting on User After Auto-Resolve' + elif log.responsibility == 'needs_verification': + resp_label = 'Waiting on User Verification' + elif log.responsibility == 'resolved_idle': + resp_label = 'Resolved / Idle Time' + else: + resp_label = 'Waiting on Requester' + msg_body = self._get_stage_conversation_body(log, t) + raw_act_main = log.activity_content or '' + if 'Isi Pesan:' in raw_act_main: + raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip() + + calc_log_days = compute_calendar_days(self.env, log.start_date, log.end_date) + log_wh = compute_working_hours(self.env, log.start_date, log.end_date) + log_wd_eff = round(log_wh / 8.0, 2) + + from datetime import timedelta as _td2 + def _wib(dt): + if not dt: + return 'Active' + try: + return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M') + except Exception: + return str(dt) + + ws2.write(row2, 1, f"#{t.external_ticket_id}", td_center_fmt) + ws2.write(row2, 2, _wib(log.start_date), td_center_fmt) + ws2.write(row2, 3, _wib(log.end_date), td_center_fmt) + ws2.write(row2, 4, log.stage_name or '-', td_fmt) + resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'it_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility in ('customer', 'user_after_auto_resolve', 'needs_verification') else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt))) + ws2.write(row2, 5, resp_label, resp_fmt) + ws2.write(row2, 6, log.actor_name or '-', td_fmt) + ws2.write(row2, 7, calc_log_days, td_num_fmt) + ws2.write(row2, 8, log.duration_hours, td_dec_fmt) + ws2.write(row2, 9, log_wh, td_dec_fmt) + ws2.write(row2, 10, log_wd_eff, td_dec_fmt) + ws2.write(row2, 11, self._strip_html(raw_act_main) or '-', td_fmt) + ws2.write(row2, 12, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt) + row2 += 1 + + avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets)) + html_out = f""" +
+

📋 Multi-Ticket Bottleneck Ranking Report Generated

+

Total Evaluated Tickets: {len(sorted_tickets)} Tickets

+

Average Resolution Time: {avg_days:.1f} Calendar Days

+
+

⚠️ Top 5 Tickets with Longest Duration:

+
    + {''.join(f'
  • {item}
  • ' for item in slowest_tickets)} +
+ Excel file is ready for team evaluation and management meetings. +
+ """ + return html_out + return html_out diff --git a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml new file mode 100644 index 0000000..d32a3f0 --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml @@ -0,0 +1,86 @@ + + + + + freshservice.ticket.bottleneck.wizard.form + freshservice.ticket.bottleneck.wizard + +
+ + + +
+
+ +
+ Ekspor Laporan Analisis Bottleneck ke Excel (.xlsx)
+ Laporan ini dirancang khusus sebagai bahan meeting untuk mengidentifikasi tahapan, penanggung jawab, dan faktor yang menyebabkan tiket tertahan lama (Waiting on Us vs Waiting on User). +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+ + +
+ +
+
+ + + + Laporan Analisis Bottleneck Excel + freshservice.ticket.bottleneck.wizard + form + new + + form,list + +