From b47e392df3926002a48e2e4bf75c8bed777cd46d Mon Sep 17 00:00:00 2001 From: Madanil Date: Wed, 28 Oct 2026 13:45:42 +0700 Subject: [PATCH] [UPD] initial commit --- .gitignore | 2 + README.md | 126 ++ gmn_freshservice/__init__.py | 7 + gmn_freshservice/__manifest__.py | 38 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 359 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 223 bytes .../__pycache__/__manifest__.cpython-314.pyc | Bin 0 -> 1338 bytes gmn_freshservice/data/gmn_demo_data_v01.xml | 123 ++ gmn_freshservice/models/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 606 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 363 bytes .../freshservice_ticket.cpython-312.pyc | Bin 0 -> 47183 bytes .../freshservice_ticket.cpython-314.pyc | Bin 0 -> 14140 bytes ...ervice_ticket_conversation.cpython-312.pyc | Bin 0 -> 1930 bytes ...ervice_ticket_conversation.cpython-314.pyc | Bin 0 -> 2036 bytes ...shservice_ticket_stage_log.cpython-312.pyc | Bin 0 -> 20081 bytes ...shservice_ticket_stage_log.cpython-314.pyc | Bin 0 -> 4546 bytes ...n_freshservice_holiday_v01.cpython-312.pyc | Bin 0 -> 12576 bytes ...ce_ticket_conversation_v01.cpython-312.pyc | Bin 0 -> 2413 bytes ...rvice_ticket_stage_log_v01.cpython-312.pyc | Bin 0 -> 23276 bytes ...mn_freshservice_ticket_v01.cpython-312.pyc | Bin 0 -> 53415 bytes ...mn_res_config_settings_v01.cpython-312.pyc | Bin 0 -> 3693 bytes .../res_config_settings.cpython-312.pyc | Bin 0 -> 3349 bytes .../res_config_settings.cpython-314.pyc | Bin 0 -> 3222 bytes .../models/gmn_freshservice_holiday_v01.py | 276 ++++ ...mn_freshservice_ticket_conversation_v01.py | 47 + .../gmn_freshservice_ticket_stage_log_v01.py | 430 +++++++ .../models/gmn_freshservice_ticket_v01.py | 1131 +++++++++++++++++ .../models/gmn_res_config_settings_v01.py | 81 ++ .../gmn_freshservice_security_v01.xml | 39 + gmn_freshservice/security/ir.model.access.csv | 12 + gmn_freshservice/services/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 303 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 213 bytes .../freshservice_client.cpython-312.pyc | Bin 0 -> 18547 bytes .../freshservice_client.cpython-314.pyc | Bin 0 -> 8477 bytes ...mn_freshservice_client_v01.cpython-312.pyc | Bin 0 -> 19383 bytes .../services/gmn_freshservice_client_v01.py | 652 ++++++++++ .../gmn_freshservice_holiday_views_v01.xml | 134 ++ ...reshservice_ticket_stage_log_views_v01.xml | 119 ++ .../gmn_freshservice_ticket_views_v01.xml | 337 +++++ gmn_freshservice/views/gmn_menu_views_v01.xml | 65 + .../gmn_res_config_settings_views_v01.xml | 46 + gmn_freshservice/wizard/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 397 bytes ...e_ticket_bottleneck_wizard.cpython-312.pyc | Bin 0 -> 46566 bytes ..._holiday_import_wizard_v01.cpython-312.pyc | Bin 0 -> 9642 bytes ...cket_bottleneck_wizard_v01.cpython-312.pyc | Bin 0 -> 54391 bytes ..._freshservice_holiday_import_wizard_v01.py | 216 ++++ ...ervice_holiday_import_wizard_views_v01.xml | 56 + ...eshservice_ticket_bottleneck_wizard_v01.py | 1077 ++++++++++++++++ ...ice_ticket_bottleneck_wizard_views_v01.xml | 86 ++ 52 files changed, 5116 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gmn_freshservice/__init__.py create mode 100644 gmn_freshservice/__manifest__.py create mode 100644 gmn_freshservice/__pycache__/__init__.cpython-312.pyc create mode 100644 gmn_freshservice/__pycache__/__init__.cpython-314.pyc create mode 100644 gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc create mode 100644 gmn_freshservice/data/gmn_demo_data_v01.xml create mode 100644 gmn_freshservice/models/__init__.py create mode 100644 gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc create mode 100644 gmn_freshservice/models/__pycache__/gmn_freshservice_holiday_v01.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc create mode 100644 gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc create mode 100644 gmn_freshservice/models/gmn_freshservice_holiday_v01.py create mode 100644 gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py create mode 100644 gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py create mode 100644 gmn_freshservice/models/gmn_freshservice_ticket_v01.py create mode 100644 gmn_freshservice/models/gmn_res_config_settings_v01.py create mode 100644 gmn_freshservice/security/gmn_freshservice_security_v01.xml create mode 100644 gmn_freshservice/security/ir.model.access.csv create mode 100644 gmn_freshservice/services/__init__.py create mode 100644 gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc create mode 100644 gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc create mode 100644 gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc create mode 100644 gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc create mode 100644 gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc create mode 100644 gmn_freshservice/services/gmn_freshservice_client_v01.py create mode 100644 gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml create mode 100644 gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml create mode 100644 gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml create mode 100644 gmn_freshservice/views/gmn_menu_views_v01.xml create mode 100644 gmn_freshservice/views/gmn_res_config_settings_views_v01.xml create mode 100644 gmn_freshservice/wizard/__init__.py create mode 100644 gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc create mode 100644 gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc create mode 100644 gmn_freshservice/wizard/__pycache__/gmn_freshservice_holiday_import_wizard_v01.cpython-312.pyc create mode 100644 gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc create mode 100644 gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_v01.py create mode 100644 gmn_freshservice/wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml create mode 100644 gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py create mode 100644 gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml 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 0000000000000000000000000000000000000000..518bc7d0d18ca1f4c5dd7e85fc970c74e7d587bb GIT binary patch literal 359 zcmYk0zfQw25XSBNqXc!M%w8jvNU#whMi4{G#t=oaOl(RZjw3rMY6o6}XW#{R2-q1S z!NSA_s#ut~Bvj&t?|$d|eEOb;LmxriJ-u8%LVs_Eby;OBlgVDkbyyf{s@j54U#UvvqlPNjY!dw}l=UFO3|5TPrSHuWeGh*tC-IAp@ zY$hTi6&JY~!>Y3aaaNGqY{@ha{SORL0^E4L4QQ}7wy(Wy8o12{rCgMLVsy zAP;B-ihd#<)4IV?49 literal 0 HcmV?d00001 diff --git a/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc b/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..283932518fcf3d352887bafd1c082ffba523b57c GIT binary patch literal 1338 zcmbVM&2HO95T+ka8-MD?Vx!j}8?-sXsg|F~cZu2Uy@j7qt8@$O|{3dVnjnx6e&hQu`@E`=q_A>=q5OV45 z?yn$>kO501d!bpb*^&TNmx8z+(h!x9LKZLbW-zDK%xN|A)P3S2##m?=m$di`T2hoC z1EA$$^E>uLC~X)=ohDM3C8BEVu#(F#yAu1TNT3YFKn7Rj9=}InzF;SU_&y-kl!WMG z%6qIIprncucK5RgFQ`+NtbfV0Nn#TAsCM*v4tM+CF~=xLOTTQ05EpeHiF{ud_9Q*P;X!J5A%y~~JCr*5f9Pm8U+VONg z+0F&N(mhJl^%8j|0#~Cc?|^paC(i3Qn%r9`on5=2u9;s@mm&$!jgsJ+`W5D<)IVTu zEiftL?1Sl_mRh4f%4PX`Dfaw_qd|Yae{^u@J{=s5=8Zb~6zaU4N3I)#6lbW7>pJOq z(Rf7JW)beGO@gb?)FWDEtCdP+R9o*U~HUhld! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..209a6671c01146af3ea1802edd1a957378bdf524 GIT binary patch literal 606 zcmZ`$!Ab)$5KVTsTdDL5^pJ~SJrt`TC?W{rQN4Is3L$RdHg-2DNw!db!K>gm_$_{b zP^cGA-W2iT$;oz=iWtJ%H+i!&FO!FMyM?~d1oUwGsGC>N>MQ-*Fv aF~)CEfTNEX;f)!By*)!aPpD&-LH`097rc-F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3f2128f9bdbb4e1ca4d8a4367af965379ad4c144 GIT binary patch literal 363 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..86c604fdaea9027e9661e1320053c8ea239a92a1 GIT binary patch literal 14140 zcmbt*ZA=?kmS`D&gTV$HjPX|yAi;syfqX!KknkBu!bb|?bUNvF7j^}zF}A0woHXv) zZFaTVq36BbPFgF4M5Be-U8!m5JvAdeX=v$5&HPwRq#Zq#Q*PN?FQY};kw)`lrR=0N zvpc``+$xtb#7@u5R?Mwi_nv$1x%ZxX?z!ijbktN=V(|Rs#?RjWpbNwP6@4g=HlKMF zh0GfEE=H&(u?ZEYnyBC^Ce)mILc?jisJvoQJE7xrkgp)rllln*XMntl&`cU9D!EE8 zR*yAfgw~D`x_4Bw4N3ecoO6%{%nH7=?MUo83(eYSlDY-(%!Uw%# zm?8i1>i=pmjD$hHgK;VX<0{_ObLs{x4nyWNL#VTUU%k&cgt8 zgdYAGdX&LbmW4D*kd;JL5A=&3t_olq<$Q4pSWQHAk4i4%ssYMXTrcOFF+WCJoKX>G zqDI1O>B%o1XMRJAL1Xy5jJ~SC|40wV_7J75tIX<&X%Oi1C;3rZ-FPA09l%Q@Iy1= z0=T}C7@LV>0C|aMDXf{NEY4O5wyn^+w=CAm)ANG42%62dSUv=K?{DJ53CL$Q(K>1n24S zGR38=_lg9gpOXt)i=`zlL*fr;ZV4YsMraP7XGl7ls#<~6&n+<&8A(~C4>HWr_i1j` zze0aN$5LJyUnmS992E(X93G+Aa3@Yjq)e)QF1heN73S~⪙0MMxietYTl<7bu7ow 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 A!vFvP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d4dc486e652548d9ebff1ac89807f7ecf2eceb9 GIT binary patch literal 1930 zcmZ`)OH3q17_RQ=Zsx%-FCNRT3msITT^xKw6Ia~03!)oJ+Vd;%*1^)i4{;&Q=)&JG} zl1#<{9p;x0NlDE5J0X1s+`JId6nk?58eil-vTg#xRP|X>HNDN z33OFx2#WX@ndmy|&=G>eh5?jXEj8mMR*W#mrV)&|0k?wYpMlvhLcyz2w8IPYu zRGP`6SwpnQpd!+%8FsZ+cai2GdIVT=8#Savf>>Q08xd)~7&T)B$D~_nvCYIsdfBbZ zbF3(%Lj=E77-!l4$rKfBunA_s#{&R-!fkRTAMm)ql$;g^`AtrH4}AzUF_I*Q%CApN)D2Er**$ z3;I#h>(F$UgLmaZK)7M^wFb`6IL(*CP4NHL&tG)1jDXG+Dq7(8+`#s!eThZcV#W*DD>1=tJltM2>;Y8Y3N% zG#k^q#EkqTx}aN)muw4>Sf^YQ5@C8>$0UL7(%!T5ibj`Wb=tFmx3sUCP&>1eu3I+k zT;0Nb%w_C0WsSi?;eIw3iWEpiIsE>oj3l&G#*MI?SsTL09(1DHK-c#sM9=KF#r z>O7!g3wYi6)>~d~sx|K=pKZ-POb_o!?bL3{i(d6o{a&We%Ut$)vZo0tBee?0X^D!{+1p2H9O)($LQa&7FBk7P$;W$Q4xv28WJ;R1lJ-iu%EHu x2%cxFkB)^zr-EL+fnT5oMhk}-k0Tt%{RxIo1(}=W_CEY(`RnC>0Mq@w{{gO#4AlSt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..816fe56a73e234eee43aa0af7f3f002707d658cd GIT binary patch literal 2036 zcmZ`)O>7%Q6rQzropl`7b{yMHX_KXtCZ?4u0VPr?sA)oo$q&)i1cB0yw!4#Ln$4~| zJFXg6s(hp308%c6TRBlql{j(a!i__PBC#4Sl{g@7krDw%-gq6d73s)+`)22RZ{BV4yE++>=>nVFzK&jB< z!07P#W-`*bYO1fGs`jUxRudw%={v*?L~4~1*9q)H3xxFEWbclGkOiAwM_e%xPux&> zvW$EP0o*0HW+r*+df!q$ZXD?llxk{b%It%`KG92prvW{5Bsy*OPrz<+@XQNQnHXx| zg{XlTDhsHSk>9w4__F3PGdGcl-trvaj5s3F0e9RSt|iR8S%6FiC*XMG;03^+iS)Px zd@;6r5m4jt^+*TQPz+TvOBKY+W|>!(5kGEL;Gj;3kr_sU^?S|!5@EKrVKpt!IBQ(BTq^*rLBW#|cit}Bj98pF*y4s2*yj!E z$Y;BF#1=y9I%tSK8z7cLs;Wo@oKbI0=#1QMQAQl0`ySAXa3VAc+TC`xK5ZO^Q&m_R zN+I+m;;m6eH`_xWL8bWrjTy@gdf1!28aUF^YCxt;mg+Tbl?PKIXph}}JW*^lto z_ZC6vpkkZ>ttJ(A!H^${j^C~PAiFXr7Yks^@!)gvevZ$ltTiW(WdN5k-|O$nlO1! z2z|lwwqEx=BGSFGQAem#>lPCkau>+ySuTcG=@yVW@|N$a)WHYqqVK8?)V4fUk}lKN zpsj4V6)VfR+5n(P-}Id=%%!SNLnm{(Nkso)S4Y(^VH|L{JZ$K5XxN*;>HzX6U~I$H z5IEH4HgR2ytBT0RUV@qA+Z8qplF5%uSI88DpFsTc8JtZxQ0u%G7B6?^!|bb_nP>U( z!`yZ*)K7)Ea#$#Zg%jbi!Tn6Ckm}TT^Qbud@Z9#fM|fxOVrMP{aArI6=+aKU)~Sb; zQxETL-+TJnm7U7uQ+4QBuJq;o&+mu1@i6=8zLw0VIml^Z5nY>38p@3F QX!+ZfZ&v<7Qt#US3%f}l;Q#;t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1bbc49544a0612600bbf4f572b12c7409488d918 GIT binary patch literal 20081 zcmd6OZEzb$me>rwL4f!m2omDwkRl~Q6iHE*EQyjRQX*warw^N?z9{YDK+cc^2?Ej# zD2Xs=^QwFo%2LXp&nZz^C8jr3S?Z+3bY&~6t=qemZ#QLc<*JU*a2L>osc3VX+Eem} zvXrVl|H^wk7<|y6Y$TObx(Yor{a(NBe*OCOd#@Y+v9Pd!g0ysFdFRZ5B#b; z^&#R{=SiQ*6F(pQQ^i`@{ODWReCg+-->O(gUh<6i=DhghV6A!LJMzTONB*(@`K&L* zugR-F`RT(d)|r=nMtpsq{mD;$oM4Z9cYC9X%}*a9zAI1q`Ru1iKfm>!pFXT&3t4Ag z{m)MyR9Iem?69@$2%UpP&Azh4qz>y^Z+w zdG*K1=A)l$^ITtupO5_4vSoS7Lw@U}7QCW-?15U4|L=%DU}3j_r(|VY*oOD$0UNu6 zb;HxnwzAvcSp?sW@N}@-0jpR=y+N@}?@??sry1Lp_MenDWex{Qrnb6wKEqYYvI!D7Fjx?Rw(rWzr!E*_(GF`5rzd$k2B*OKjvk)VQxI+8)jIq-#ftw-q0j7 z>g9cmHxTmjUMA!b;28pN0N%L(%QWEpFu}nwZYU(Un+q67C%M1~H#F(>`^EupkmtPP zOzY0J*LF0uz6Lp|&H5Psq?c#Lpc%R89eWAJ9>`@f$yubRq0~$PGjIkS@Ov=;*=;I9 zjlpw$V(Bo<9>7xbn#N#N2n$02z%}V|@wV4RM~3Z)ltg zdAyUXFLX(k>YNC`=@HK34~{fno|1H-pa z0hp9qC#fgA*CmtZ-09vw^7Qq0_Vz=GFCZD^lI~MolG$^(x4Tn;79_s_0=4x?@sPAc zHh32hh4)~{>zDLI3_ODJ!U?#fP+Ay<2T7%*(hY{j*8|iBKjBXyo1tD7P}ZX8*1P*f z%hs7URxS2u{oQ?{rGBP6p*KhMkM*SqYtdI4TD$kTjw*CSci%jD=jg(oADoVv8)v#- z>g3u7L`#EGyG^ugnRz3jw?s=H>s<*$;oO$_(m!wc^kLyMj6^-Fz^oX{{yUc85_sms41|;#N*a;yN>SJcqUX*- zzd+BCLd?3T57a}dQEEu_J*Ymd1`P(^*HuJ|=_2GQ(6_7s2)qSU5%q_vTTTr%tpQAm zH-}PfL?BS7Xc9pc(ezP9SWB!5yFG@(TbGou*%*#+UBWQfQQ!Mx;H5628^cLlmw=nL zh-M7uXH@*IMqO85psvwwWB6h(7n{Qq3 zQ{8>XIz2t7PaNy&ywE4Deg?LFLOtL{#>t23nl*h#?>E%IiJjy+fV$CD}`A!6? zy)4V4I7@~u#Z<9_LJo1)L!jX&IMCh`mnF?4XnpJt>>0-H>qFe-kS`c;>m@Z8xGEKp z%@ou(Kn*Z_`Hf~JHH=r%C5Nb$pV%1DmWsZ#c#@nX)dc=LAoRdbfDk#T$^Ta377LEW zO59QV;=se!m8PCYhTk;xyx+Q9;+{FOTyX4($@0<2{LsR|m6BG_n6m|IDxGvSwxfiP(-4f6M;e=&we_ z>T}EH^NGTedClF?cvW+(s(CT|vt2*m`_sK*RoAk)drhnEpbK75>LSyu?uCIWshI6f zSc^Y+_vX6^o8z{APJdVTvGtBM?%Ee~?R&WS=M6t?c+~yH>CaD#uKu{|Ld?J*TQDt#|>Lospg;S zA^Y)Rb5{lR3%b(*k6%_8yWHwuHqelt%B??7!OVk&K?{n7hc*{zO=aawYa%KhjvAmW zr4s^rUPYO0%X+>()JvT19XfwZ6AIEs%PnG189RB zIyTj&F`|DZydbS3BUHo~DNqdcX%l|oJ`tsPOT;*4f)1@qN-L~({1Ql^25OkDQoJo< z%;>f5fnMQW7_|Dd@m5m?ztDhRm!Nh^$17TG+Wf6`QoBK_PGJbQOp?B}c1mBf2G*cZ zP@WN;@-}S=;Xa-(O(PsMr8uk5is+TMX=}Qt5lh6JDz~Lui&!Itsd9V79KRldgI7iATb5Aj*eqgTO<|A|2w^6zaImTiC&xiM zp@T3gJ0EHLUvWK>u0~Qf%Dx6sj-lp)0@Q$=uhd3r}OdkhZ8B{nvIpGa4 z4Sn80aErDuCt-gZnS`2t?wU8`Q)*&|z`N->47pdBoDhf?1RY`i>z{oHW#hrXRgM>s zI_V<|%z)joaS!@J@(AUPRgi~>Lmu`Ay`eUSn9><8lftqF);Bom_lD5j+6n%g;G6LJ z<^J%v60#g9+{th$y!aZjZ*zlrhNZ#8frUR!8#7$uF*IA(B~TeDvk(o5K1xGPQK6Ko zKoe0B1-=E$nHig0~{i&kjZ(udo4}(t@_63H6ZkoRceQ_J5 z0-xXufZPU#I7uypc-f9Hz#DA)5X&ey>-A4^-8>)U`8S{nsVC`q?y}ztRg9CNq2Tba zz=hl@NyUaF_3%VUz_p`n`DSqL0$$%$&I737Mlet=gEyM;o6toSvOK{Q@)%GNB9P5c zt0rf3@b2anQ`LVey%1Hesnqt$)$+~p^6jzm?Tfuj4zYaq#!={qM&d`wp`9;5FG$I}HG03rYHG95)W8Ab%W-P%rW5kMSVP znf6>gd`enAI0^oI5&sr`!IVLi!vj;GlQj>|5+ninE@=t-C9UAO<{QK>d=!$ksd(j* zW@wV<`94BSF?i8fost76Xq--A{6sPe9Qg9soTSHBEK`FYjTtAPOM&Y@JIVgCGw1a4 zVW=_yKj9`Z1E0Gnb1^!^x&mzxd=ze)cZufOneHcb+iskgFIh1&KQ{ct`k7VS(K*xo zTa)dMcEPz=|HCS%vj+Zi(MQJ?I#-If#LQd3!8Y47Kc5zt&YVn`Y#+4UXuEk}rVH)4 z@Qv`z$V|_wwGvFrU5hT!x^t%IX@Mo$AQqH8HCaD6aO1$u_W8FSo2nM-78_!Ib z5N%y4u8*1PXS)*SV(`(OmGR=bSaID#|AYNv@t#@ZYH>xXWS>~PdsdHFuDgb~t0CrU zSS)@JTIyW7az8A(+8%a`#fPyV#^`X)$yoaEbbvBAe7*qzW||xSv7_d zw$7h}P{OSGsmT&`yuUAD-n3G=YpL%i=RZ6D?*<+@zHoi+`bC*oc_L;$F?;fqB2CT6Ia)vPsACTp}|rJ(F(GiA0%D;G*)_N|L{(bTxCX(Ta&-0{f2fa$_W7TAK| zSrc#E6rp()s|wJpdRoPTo%sXmU#R-BVwP}Jkla_Z;)?2YER*cMX)I0J?ulqvEk?l< zl?q>=rokr7Xf>i{bwALsV55?!f%HKOd$@5j03ALWZo5capulp|yE8Lw4Uk!ic1ME7`n>9tazl zhE5Dmx+N;C-GS&t4}{gtyGFt~xq8@~S=+7RQT2iMM`Bt%r_c2EdEV^q^?>^a5i1kX zIUcgTN=BdH@eYMRy@De+IXvvUF43cs>KZE*Tm#?CCxDus`exW_m@p!xgjq07(8=T9 zgLXfLpD+d44E5YVnM>yz7wwPBwmqqCm@8WxoYf`lCDE(V(PeA(vY~p_P&hk2-zpj^ zXF6Anmf0&Sjx90kmPJ}LZksv$J3!^FUzxvI#;}hh(wSKo^vRJ2$$tFa9-@D((#iaF9{!(->l|OR$S4 zcjD``P)1|TO!`UeTowZ0R6e1S50M(q$;Ts17e*Lcn8TA` zl5>JUzTM0q$FmUeU*Dvo(ecTWpb+O#AchVnXU^~k=%p!+j zvIE$VG%pYCc9RpWq&t`@e%-yDbXed<(NXlu-SmT}#xvgWQ6CRIz<$X7XNcpI9P3AYVn!6dXRsUaxMpF%{$mdqH#_9THM9?YLW;7QD| zuB1lCqzgZBC*^&Zy@?rd08x9n3yIUgk7KDHGRd}nUL`%okv*ZXWFjv-oc-}E--|Vf zkD-HB&|NX&=^&m53OtXLbb=gH)^T!>7_=ybbCF8xTnQz-E|d5Wu>URm1T!o?uwcrF zD`8tx)oV8;ik!C_=NfOf%(X1kiA7r<7xgT@_UYbxdq3TOZ$ErIY@XF8N;cgdog0mp zG{#CA7q2asG>RpAW=*Tk^4p#{Pu%%>%=!A#+mCug=gGLUH|FdWo&B=~PfYgJ(#qTK z&b=Ei-5V?2`>_1cuvmIBUfLTg?G;P=Z}j|5A#YuvskX5X`7-?uDGf#qnjN6$Vsl|OM5Ke1M?I!dBf=3iep zdgoBQY)7nYhv;a1Y;H{yJ8@XfyT*kpD=v4;>XwH!{C*&xQ4QY=kU>2*xYo*HKwk^{ zKq~&UOWj>f{nBW|ysT3NkN;3fV_to@;FwMQMPoVSUm7}9@c5O9>Lo!lqO6)_2cuBdTuCqEYc2T%9|G5@r1k|ua1#LTrcB|ndx(D*O3B(5a0IZX zHZ~y-Y^3YV`CJ173Mfg8>9A z)DXsL!v}geKWvZ8I0Tr$nE`(JLS8|3{~0`pO9t=n;5+KZtRR>>A+j0N(#V+!n}YsNb4OlFn4Z zTDoeo+?tGDT-X(JZWXN!qR9=xh;Q%?>ZKBj9i|uCVCrtepFaj@mKsp8^njXG4QN>P zfENDZp&Lt`J;>;ov*0_!#VI|jdtkVa!Bj{C_=l66C%~)`@Cp=h9Vj4psk2|XHs&J~ zYx*G#7XWo|=~4HJm4gl+jC%+vlUsGr34c<>UD{&L`=5o zLG^u&3>w%(a512^F0srys%0Betzq%XTqf1>4RJSr1Gi$s9@HUj1_vquvudn;T}R;) zu0JL<0A9ya+^7L&L;S65J=>t1;=z@@be)=4)luXXF88Ht)J`?Jw@YP5GcGgrle5^4+ zzYlIlg-b|Clo&0HR~XvP_*i^~9XVQ%w4_cySZRRbWjVJ73?CAGk_4O10uV=%hrvpq6ynvZ~qLc9n%$r`Y=~%=ug}^Id zJ(s~AoeFQsXsRE}0TIe`RC6dD#TXTIM}X*4xH+SGs{36jcn&glVQ7@Y>uRiLf`h9U zfsydeez+e9M_h;DyvjS_g-Qwv-0>qQa4PnVVc6jg1Yw*UP4mHEYPyp}OeY4|peRQN z3h|bQ|0=ZaWBpvXvN!q3G{|Dc9PtN%Q!U2;>JD4_(P_i`i^sj!yo3A?ao#lPIfM|q z?7{J$03>XAlk5wNfffW}@B zAh^u|6Kp*LMmT_!c?PJiZwf#Y{J+5_O#Qyg93!{GMs!L=;DNN-ggRZes?dB5*Y11D{l&(yb=LAa_;p zC?k@ZQ@i)J{4^rYaos__W&9t}r*l4dX%^ph5e;Rds(0XKLUFWU@d z8X!EnJmIE|)m(FG(5ZRo2CXb$z-5N|dq?T)kyWN{q3=hRKDi_^JC`eV&K~)vC!ed~ zB#jtxvfqZrW7g19<{X4nm7#-^iKf}u`q9ESKa^?K<%+=Ot_P*q(z>K<5nxY)MeRv$ znsC7mj72#`z&JwFF@1&$cqvLDYZ?z)uqj{ZC!F6Vrv>Vz;=H$bINZo+k9?)5vJkt? zz@5d(bn8y>Xq6FRy+_MNIJPIzP3{7jw_$|iNuKN-7b?OjpD-dfr4pr#So7TQFx+2; z$UlkN6Pl7Mg^`TtD@OpZ=pG{NpCTRAwb3l=pZ_tGhF|+G(xcE%E}7#fLsy}8Qik9F z?t^#Ue`nRXZE+Vm)^G~-{m;JtsPBtQpI;K4y-`)7Zc9`Rhfk|bJC}AoRDZVj;ag(U zAuQUq+H&*}zkKF={LEnN%-~APP*nYE+xDk*TS z#VGwfu;4ZlNw^!N{(m8qxkZ>ipSv7NfvM{ghC|`pEG6D}JAMqqBkL0CaZGS)9B>s8 zp4MatJa`LAIf!fO%0b30p45Q}z@c3^-TG1fcR-ci{Qoc}R`VcrwwO6CxCKYya0 zM1Z#$e1Q|i3@!k6BU#plQ{<`&j~*jg+x`4s;wvf*{%4TEX+H^gVN@z}aVuMz>}I2< z&R@ZdEOSc=DzAO?!X;a(FnOq#5xILG5Zv1I0w?~dq$Ph#;W+>b=< z{kyDprPXt|(AS_EL;-NKAPfEGJFBJJ7yCawfA9Q~@RN5wd*_kxMdb4c#Ji%JL|uct zZXG4J4}5qaQBnQzi907C+}3`tJyx+V+Pzv)y`Ww=6R+PJtKTcuw>>-?t3M)EbOFTO zyg0e!y`s`G!;&7f6m*4DPX*?4> z@|)s@r*-vmSL;caYZF_yMb*Et)jh9QRLm*&X35}zYb+C-$0HSZlK+u_ceBTH1^zUW zglGv06vB0;A+Ve9OjXi(AnwWW$(y$)y@9EnaJv})IYjqp_)Jw6~g+gKuiv@ za}aU5N6B^qUM}Q+pk!aci|~&iJMU2m{3k=g(LQq`QCL57^r@{ns=IBSvnC9c32RBh z?o8OrApY^vq_gX0j;-0KqRME=?ee+u<+@!DLo4>Knd48L_0h9&XMN0B{}pB2s#`0f ztksLfOW`kzUpkB>r|FqfYbBZWR_wcHjwkFj(UQ2mCT6esiqdS>L0HgQv2gCCQB%|m zmu}6JYjgDK?YHONUfy3M3ag5F!^ED0-PTkB&XsM(&diM6Exl0La zr3`|Rzcgw~bkK22svi&>N>y~}pg&O4Sp!5*%$-PB%M!M2Kvb?t=vt_%ZS!Y8zH|rZ zZq9^OLFFbDy6Y(LcY ziW*m3QyJ4#ikj**EkbJCi*-x%Qma_AJ6_Wkt7#K!4#<%9qmM#gzP0@3TVi`}y!~9P z{hZi-fq>vILfS$?a8T52ifgK3nkrGltm%Ouz&1B7?pm@h9Tsc%#A^@4Y7dCD?Fy`u zK1P38{NYN){vWQ5|$?0CwBD5J1)dJE{Gi$*9f>k zA)7sX6Ot{UYPS&v>=bKWkJs#v)$A8*4z49(9dr-_l4q5k-x3$a5N#6uTl zuvsCS&4fZEYtG%dWQ%38MN7S6?cR9p!C37r~T)gvAtn-rC`F$DgQs`zgcN5Ze(r3fXMdZbh|nj|buHe1yYa6@jgZY0|vlU0Gf zt=SW=IS{KkAl9_6C1FV>@XLK?#E!G^j*GF5i(<$3WbpRfWSfxe_T0Tq!ZzwH0XHjT zvn8Pg$u=vz&6b2DY@;b7;2jFtY~Hpa*&Rx6v&A+EONni-*xna!KObv9FSZY?5$H~Z zYIbK|L#jKK&Sncj61EW!2zXa+vb&M&E`@A150bErctF6f%Vc5m7d2&ZO?6CDEoy4k z_5fMj@Y}7l^+nxbn%@56xXMg-&@ZZX(VJfQX)Rs$l?AewmMNNUTqyZb#U~a26Yf0G I@(tVn1+c(aqyPW_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..692040bccf39c0f68016bcb4b2d7caec7448b7da GIT binary patch literal 4546 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1724d1cc952e2bc59252166038bc1814b481358 GIT binary patch literal 12576 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 MpZ{+T-&vji3oCto?EnA( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..938db9540824e1336f7a829eef2448a1936ac474 GIT binary patch literal 2413 zcmZ`*TWs4@7`7e9xh*7VdfCR-9mZG_R#G+uLSsX0?Fyyqm{ZnwzVw+Q2!HU!?i24$wlBhDO&~%|AR>_ru_4u@MywVS1xa{YAhA0Fk*Tzl z+!gG-H)7$u7H`GNig$0uBGf=l({0vKgPJY9VPg|9{S(~6F0v`pa2@lllD3H5F`GKr z9AnfrT4h8q(~+y!sf#8Vbrv1UmUWF*($}b~V;jxExq~pl23|xCcAIb?h>ya7UJs?4 zAcGpXX*bPq120e$O;s!DKe?S{$yXZOLdRF;b!ret>$xHbiSA;1UgYthLQRC2NJOl~ zh*XnFtQIG7O_2neSjtBQ!#Rl)g(OIlB<`m+y0NXL0(AdA==A-}2I%2WE%Q{=fe7lr zQ&HInDjT4Vgf~Y=@(jt{&u>WKUM&~kP!ti)NrCh&NTf*m!IOavxr;9Z_!Il!2P4{x z0jjhQYAAvlAP4UVwZYb*@?n2Cn(V5sLH@@rbD1(ov2K~3JF~~5kS=pT0LVMCfeImC zDrp4Igl+@5^d9?g!`6*DAazGaoNtH#!SGy^hXjsccV0Irx^zRMMtGNp=A#sJ1G6ex z)UJ`MJvN_27rRDPpWv@Is4nfaJK8AH34eRVeN_1I;DV~3&;Wu7Ez7>*xJ)+} z{3N5-n>wS!Pg*9Bn7Y)D>oB|FWv3=k&z!QzQjh>Yd6k+AI@23o{|CG<02Gb2H*}TP z51PRaRBLI1mmkNKz3D1zQnaMkF~p|MZ$Oqo7twgrajgbrWAB3{)Nv^rMU&WYy4c;s z1ciK|TX|(EG)7*eh7AQ6=OyWSeN$C*t!dj9b5V(7-7*{|wXOy)A6Q)4Q&je{Ro$lG zW$2RMuWDAqZn{);D8zuNZp)_SSg0(~J@eyWFr!{-3K$T0>t#N|b!Z*a+$*hHe*s&0 zC*!~Eb1o_ip*(kSw%fW!YP=fFf3X}4S9sWWb^4iTFeP7hs4?%y7}Xr!HW2z}k(`~ih-IPEug~%ZOe6Gr}UFE*leA#OzNk)&gsOL#}_g4GxP?OjEM~u zwo?3C72cK)raDn~&;7(j3knXKY=B>xx(3CraaC-PujMKL{nVUATB^&Z$qDE$uHK-2 zy4P!h0DdR1!yv*A@d3X7;lMxS&aW`s5gtiWJh`O^*|GMePVrp(awq#jd-7ra*ov~2 zTg`QnM?1N}PGO)^INW)rZzrP^l=jqCUMLQ)oLoD#dTL$W?0dC6-N`=-0-4p!`q|BV zr9IUdI=-^Jc60USgBRc09J=s8?thp&dUyG&+n?X=alOL zn}yff?{3RVd{PYk<*r^SD{L6vzRU^Bcx{I%iUf7Ms5OaK4? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..58424e6e1b6ddd4ce5cd9991049bcee6edc0af07 GIT binary patch literal 23276 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f3dc9f15f7d12c15cf2d5495b39a40c99b770ec5 GIT binary patch literal 3693 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 N>xt2SGw#rp_kX}2DxUxV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..253d940a03da66934f1917600a02fada44adb04b GIT binary patch literal 3349 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..796d39fd51ad5b36784d8c166ee2dbae6919e6f8 GIT binary patch literal 303 zcmX@j%ge<81a+sbWpxAT#~=<2FhLogMSzUy3@Hpz3@MB$OgW6XOi@gX43*59ELBbc ziOJcC>8T3EsYPX($*ILw3Q4I&nZ=n3$vK&+c_j*^c_pRU3fcL2so6lm>{JD}qSWFH zsA>hr08hP_j6mZx8E;9a=jO$yA<4!=jEFBYF!a-8zQrCNpOT*(AAgIXh#9D3CBtWs z`+hm6SjFV0Sz89svyy5*c=731sZ;^^n;6YuWp z7gLg%omvuuqO~M587LN?SelYq5(71_I3_+mGcU6wK3=b&@)n0pZhlH>PO4oI2hdEA U{ly^02WCb_#^(%*MQlJ007A7k@Bjb+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c8ce8986b5f825630a588776a6b758189a6e136 GIT binary patch literal 18547 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 BOOyZr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61a6cd2441c8d5f2faffcf555735706ba9b8fa8c GIT binary patch literal 8477 zcmb6;TTC2Rmeub!(9q4p7;^#hXdAi#gM+c11dJcVfJuRINPte!Rdkp8Q9V`Fw&@wq zc)pfMqqQB4WTN>9yILvPKY#i&%2!re?G^zoy)_;w((KRt^~Bjpw5yf&oLk+{5N5q8 z!R`Av_ndR@J@>rs*(#r#fp2{CpYQf|GRz;bQU0u@%BxkVJYrTD)-=he#;-{=(Y9GN z!>@VLa>J@xTbX6kEX7wwVf?l+%l09YQCA&7rj21O4Ge1?GMDkS4UFn!y->PXAC&H84_np143)^KUbb5GEih^o zTLWy?>b2KoK}i6M_r#cRB_#?OHUDpJqe3{I%c_D*av3$3Bw|Jt;xea*q;d`!o1G;0 z2E*=YA;agAxlEk!IgX?yUKn)`gvqoNOG3d5r01F>r#Y4IoXU}b{{BuP@<2{Z68MR8 z8Ny4+TpD_i1aOu9F`KD*?YGE66#8 zOK@B!B%R2H2DCbK_2Z$555K$Jv^ zVea&2kS6_DGn|`6XLM6!CS=v^vZBgjR(Gy)iV)4oDc!+k#b{Dk*PS`&NyGL7m{k!{ zYjQ1Mkn6ArK%wB8y5Q4i_|c{5v5B#n$*Jh|>6s-s6G2^CtB{PSD#oBM%H?=bT`Gx- zau_5=r6-&P%F(Ep5!Gl^Z>|&-L#o2%!B*s<7m7zrp{ecDQ=6x@*uBQcc4OqZWv6jy z!}=Rvq2biChff}EwL`;OXe58@dj;@JY=38Cb%bZWct>X zo8;=u^~ss5CABd*F-kg>{2nVxjsbH7-Q=lEn{UNg)gw51lCFvD;1y8=KORmT6`ZYm~CCZ|` zmP@6gxHLU8*P__0g2pw5WZ+?|OwoUBg~o zXuB@-d1$9@Nb?T;M;T!HEA?L={?kKkCBExT{0K~L0?dbG9VHQ@#`&ri%Gb&|-1L(u zzx&b-vrAw3KE%EBKJ4EDmMP11!gTyh)^yIC1-ryrSSxFZn6nmuE2FLW^)jq&!33p! z(3-U_*~>%6-Gw&h&iI05CBQn~uo`e#+k)*l^SkoZ$bE+4yN+<}JRTaTyI=&?c=Yo! zgVu_b+GeYdLNNO_bJq3aG1eVn$~q!zf6WefUWgB@t2XY2gQ ze0#(Dd{3LgOxE=p-hIHk`?b9P{Sn?h3!c~V?)k~QR~_Nq%eoirtari1RxNlV7O;!e z`SEeEgXA_l1zIsfy@Z>>`e9XbpSvrr5?)M7nKdz+XYkzA6%{ROPzwP&4GLa01Z&pcnMBoS>+RZUMEbdywm#5{-e!rdw6vfvQ_^ z#UUS6r7{|A8N6#ohC9gUZtyrt3RHK4zfi7wN*b4LRuuvru&6r_5ZZe6L1&agIuQ~X zrp}pBt39*;3a9vhc2^fU_^3jcPa#XEkI~-LVpLj&L4Z_#1tuJPkLtR|4^Si;@8}5Q9xaVox_Oxxy?Rt8U z#;K{V-rt*!ZBNIvDPH5_T0-7S+}}>z*WwSflj}RRdCi+I)RSk@Q)#EZd$0cNcKzAU zZ#-Y!slTw{0opBXpH6==z4?}QX7t5{7iYitKx=w;qv~btiD$hZ_iq03i*9Z1_HOOM zhNCb(xi@}$d;GSxxU@UIwAuY>?4c$#?fAm$xUEHICn%OQgm2r+(pLfa{t)G zHNeS$V0vFwf!TV*EFHMX$GO@2ZWx-R2*hFw=2;Z$BVRfEDVsh1h}l~(myN-+Y30|e zCNOQb1v52X7HBzGCzP%gE0pdP?D4Faq4csoD680NC~H_hl(lRflmRvfWj)&fWh2`J z#T0tdw;)m^J^dr2=lVm?ye2}dMOZ<}f{QE= zDJ3Mx7zT$FW_U0}`ragW1)d{=gQ(R-6(*tM4Txurn3G^K0KhLGN(2WmaERkMl1zcx zUjtqs_Jz1jhN%>GoJ(=wkKYs^Rz*e=XdMQIM*EHVgCpT#a+gbkIV(>qQJl<>Sy_sM zs)gwl6As`po|iLR>VQjF8x4qI!g;!A-7+wEL57%#(aj>SJCxk&T?n@Gmu|v(fHQJa z%HS4~cYrM#!*`Lnu~|~eAM`;Kl2u{jGF&DmkPqmYb?ByLy@nT*7{sj@ZbZ?_;c)U5>NVMuH2!bGwpa^%lB*{X~Aw}Sj`uia)ii?k0sI^PbRpG zNP6&4LVEQ9h>(U@DqqhbOc=Nm-%hQkxlA}Fg<;2l2#O0B{xXs4wWdXbNO^z~X&Q&f zjLdNvRZ=9!V23Vf7P^MS$s!Sj6s{qQ$WMIAQseX#3U2!)P949ruxpb&OGCq!4*E9MW* z54-_Gbkv61uIROEcxR&JO*oF=@F1Pw0T?QKz?1GW@-a}I(S4VUa)Ct~%g!@eWA*iX~yXh|0@NXb|L^lb#abt7`copd2<-_xs>;Q3D&4El|2zy6Z zC9|;e;G2~aQ-%yteW1Ic5f=<3R@4wEFH|Ln5SgQZIm;JIw^8+wpE|5LMj=EuNP<+0 zX|5L}83YINW@3s<=J1vS+PDDni<;@a2tg{`apQu8^PY;Eoxd|@=oiRXt#S!WeU&uL zD48y+7E~wH%M^hcqMV#1V>}OB8`mi-fG80GQNvPyA zARyv@g7MH@s}jF%sGOA>TuLIdphWO)_m;H3lH(B-2kA%Se8S`=HcE#CONUcSDjcD! zlEIUQr6WJ3#ioEkFhJ*kJV>mRhUH$<9e4;Rnl4&F5fuLd?BrvHw!*{)@N5{Piu2ju z+i?zZK9merfI(wBMMz~PIgn~HH8z7r;L7Z6vMR_)$YExQBy*W{;Vtodq;zFYL?N37 zbAr027mrTi)*%&Jd2Eng#dFF)eW=xWK${5{`(k4`F zqH$oWoSX|ojFseor?{co=|;nL#lV6msYHokh?gvo(bOHH0%t9%x&>}i$Z`;Tphm}? zR8V6>3}ATvEvNwgs^?#sE5clb@+azBepk=b2A?eM1-rFi_g3eQzh}cvoz)`K&`?#V z32ZHVw(@r?d!55ycMktHw%PUDue?u25dA^sLRDBY@DEt3&mz^oMikh+Nq&p3pP%b8m-tMKwdl1Uu?rh zJL7LG1bVbHZx;d(+gvCFTDJm)K!?_OvFNg$s4aFNR6pZu`XH=~lP_)n&h z!5L?ML}Si$uDPn1FfqQ9r9EgZGEDop88^cQ>)HG{?3&hg38ri9BSoJB7GGOhJO#b} z{{z+9))1xj0OioJD7ChS{}<_VZKtY=ADAT*hFFrR9Am+~-TyEdM`NrF=*2W*IW97u zFYpDc854()+_jzN4QJUH^AD4Ekpm)sM z+;NG%a$I-pS|G}MqqIiK%`r(pcK@}ngCWdPO_@(S!?z=!S<4c>s?cv1lg&py*0E?g zNc6YBOJUaXmIZg%sEH<#xoBx&L{rF_Pk_yi8`&F?OwQdRBj@`E2tM$D%IrapMM{j4 z;~cgSn*Lk7B!MPY1rD4`@}%>|Xy^23=Ul$U$Wnn@2{F$;^6pNh8}s!c4`y?+DGnTJXL(fjt;t~b z6p7k2;2!n_oG%60%7f9?o%=OQPL1hKSm7FW>8`5}Fs%kbf!vIf9QQdH{RMcz0)rq$ z(K6U#DS^xAW?t1zg7Jn0c_1UBUq#u(EI!=>y{$6zD?L9Q2Kg5tqkICzBj$TMQ`7M8 zb&W-{%hgl}G(Wrb^w!pu&l`6FgBw?0`kVIr?c4tLJ%9hUzyEn~$N$F0_&3%5$Lk-h ze_egL5Nvq1{B-#rM)o>}wmXLQI!3lTMs|W1_JU*E!LhFbT5xP9IP=6@s0%*3`1GRI zI=EAJPV=7o;bnE>cZ}K9gpUTdKDwoyy!hhIuJ^_t>KgyMXoLR$QW}A~-;6uQgUpvf z&lQv9OHx06$@1kT8`Qxv()T?2vWJg)Gw@Q>no!lOG8&11&wfTv)@)kQezlbdvb2u`=0yA;y+2q+}I5DFyKQ zqtO`nmSF#5YD|DfnJ7r8uc8yC;jMk!F#Y&;A!C7QdL;wJ4}Sxv`De4u<^-GSa&Me3 zI9(eTuo%T+1T0&v{n0zd;4tmNf`Z{-!2W3JyDE#V?OV6kHvH|$lQ#FamwsWgxr@KB zF!tI%n|-#XS6KTC)@TZgG7xgfNg%H~@VN@4Rz8cZY6-4oSSSm&BBSG?yCB&*MPIe# zb!by@r%3*hF%Z%hD(5BRRNj;~00OpeDOXf&>9h1CUG% z=<#NqDtgB$Q<<$2HTIM^n@x>ep2{kxs3*zG-S^tRr!-H@^<&_d(VBJd(L;x#ov^cIyqc#{ky-N`~ED){a1R?FKhPU ziHL_AoXB0~M3ZO^nJ$~y-F(^1?v~3I+$|yNg6*=+#F;tCJm=Dh zF(J;MjZDq+9U`82F}|>{lr1zSMPtG6jKACYkg7K3H>r+`DEYW7N8~B3wpwV*zC20b z;RYvh=opSV1s!4*EuvMliA5h;FI&Z8(Jnefr&xkFTade4g#Ti(N_4$rx@;Fq-{CGh z#4?0V(T%V~tQO1h#U<8=UffH?3dEL)K7?+`BUZk{eVi@&a`}?SU#ohJ-J^A3;=eh0 zT-p8{IAd&g_0%E8r4B*073-mLp{oUGV(U@wKj|F9ED#o9h zm^jZ*384_p0aR%*g1YmIk{pi;vjT>aWmm0{MJcQnBM>oksvTuTLRTeGEt(2NqLS#h zKBRf}&_V&t>xVRUswEV;rWP$oVlcj-T4#eZvuaUXo z5iRzb<}Kh)RLD92({?UO2-C!_k-!#@g93;vEYhhZM81 z*kW|Jr)Mg%fc3J}J#AQR-H6n>S)(f~1_Sfb(l8&3%u8W5X|&ZEiLtRq^$fL$qY?{P zXv6$G7H6EFkmFJ(zbMQLL2cR;Sb98ax+vt@NV8QAOR*ePl~YYUC&v6%wMdS}uA!h7L6pXlTJ+^h;_zz`F%seX_OttIGYetVS&WCI zz}0^CX6%pGvRo+ol`nxQ#=HVod;7WMM1*_!%inzLL_OEJll6NjtT8 zu6$eZc-3nM+D#_z8FWhxbW2QJtU{-gH0fWK9ZAQt#_f3|PdaGi#Al3p(nM*NOs!nZ zr_g{UCUW7`-#hJ9JvL4x6~1!*B7FJTv$nw9)ARZ%s) z@});uTnq)Lgjg^V?wN~5!XKFvuA>p~ig2uJVrfxQi$cx+ZJo|ObqlA~&IEVv*=;<1OHQwcVfSanVZ!D0fawxl{jg8pdYRhzUR z1Vf44Cxsay#7|4Hsae*r7~d8JXOp*Xb;F1q^OrKtqq+i&c!0G?<4DAtXxsW$9MVTr zwaiE{wFDeH7LNv|u+^$oYNBe>*qsG8)p7hyY95-e76(F+nHfn|i?0cCn1q)SxsFlP z3JQucu^G9Y;w;z`R0|lq?5DWh6zri7j_G(Pq&3wfMQOnhd(2*PJtZkay|R_3E=e>O zU~F5H^dMXG6t=IoxxaH)uMOTh`2B-QMWs?wu^QW`?nvXWq(iBw-l*7_uGqQ$O1h#u zq9#qE9nJMVsHz4flUO{uBBWxr|vzd~5XP==yx7q5p2fzB`kv4rPabEp)s2 z_UP>csfOn^8V;r#4rUq--EElubm-^%f3pAcmorB%;_cq0*HW)bnM>2i(*4}3b;Hw~ z_B5||q&<6-YCh%RH%mEB`=*cE|fy;X6S|Msba^VB}(OlbFgSH=71 z-aVIUIQ4l)YU1LC>!q~orQdB@=-Zc3+QI(i(fVWc+)wKr$8DCM?yNq}S^k={;kodZ z@DJ$z{|~o>6aNplgyOGdzs0{N`}JUG*~QqeJ?7Ql=PLE5{9P;o`?YUjzpiEbTXwKt zb~mu!(!`4f_A5$@f*g||m;ax0V0i~BBJ&h9BS`pk9;*>2nLpYix6=2W6kvI9 zYGwSR$H+bNU%!ThmS`-rB!JYh?Hp7lZX@?n&__W(1w$07BO5D2XaMBjhzsSV3$qUt`8L6YbAnhuhMfBAQSY7*DKP+j*<&`(2DD zdRN7bs`fPgO4=DuY)x0RuAfX-bf#RLo7NJ?tG@?NOtn7uN%5Vfly~I!V2agsH{QJd z=Jyge-n#zQdg#uPOf}e+Qe986>qVLBU3aUyZucXqvzOQzpZ4(U)oG6(NmffX!Lr&n zJzU!^;$}5zd(B#P+TOf{o4uOnW@kUYmU`vVhU;?Lb(wLqS9NZ7r2J?(_gT5)=mE=T zb=61rT0Yxr!}AtyMhY7faQ{6Xz|9H^x4U9Fv38DfY`Y)=nRJm40v9(|ye$D<^J3LF z_YKs{pkOaZAtw|eMFG*%Sccz{_!mxnc0ui)5tyU|6-kQmA-vBEi<+tg#afCD=hAlZ z6TvA;bWDmt6p8YC`0+C%{5YhBs3FlvTnxsbcOeNsG8GG64aS0!F#&|8s}O*gBo*a5 zE=Z88q$uCck3pl0=2gGM3;EhHji1$LD-XFE6!^2}j-KX^kDVMHJD$ZGMvsyFljx`X zv++rw3PJwN=u5}9=7sW4$+qP27qRuu)~{*}hNmNHd4QH4>6C#*VJQ?5M5ftg<3j*_ z^mxBnTkDP(>!(0ajMD7jvrD*&32lMwM<)DGvl9LStGIB|0H{UB5kPk<<6NHi5B|wP z+Pu%adnQ$TDfOy&&n2;S>`zztZ~HS9gDKbGuk{3HKac&#0F{CS%lE}gQ8`JgNx;~!@1lxo6iaT$&$2+B|moM^i5~dIhWgj z^C`tr(l=4t)A(LsTmCqkw2;0hmd)gAUy>||npaI>DOqxid;K3cZrPP|70l(*<~AP>1F#^*@f#` zsZ(7pOP0w#j5w)E+pc7po(e6g`WkB0db(wI(k)klS^>Q_nd@&*t08LdrJm7~wRr@g z;CR&O3`Mn28o7k8%)d430 zme8V8gD}f4dkpm=U3A+eHY7dzx@Jg1DGJjuLFTnR z34r0GAjiL|hcmbm*uu0B55@TY-rlVl0Q&2cjhjFg8RvmSOk#%s{t5&dyUdKlF9gnK2 zoS7QLAU`g}$g~n|-ljgt#AhFh2oeW%MMB$_qlY3g(3!yG(!nWN5@M1V5Mu3GTnr%3 zK~aiMwdOoNSNvw6e*+r#$cmTgY=KjZo{kgeJUP}MHT|nK`A6g z)m>Q(9@z0Tv#tQ(3E(LZxd!7^ATpg5SP}z9-3i7Ls9YFr));=43}F}?NK+lmgwhuE zGZAcnS+O*@3L}Aex+UMV99h^hk{WJRp($N8dC5!5m#s?F0Iiec0lNyUF_wj3xS&FI zkVfDbr7M!&1Y9TPwKLTkLljfl0I-q4&0o%BIQa~vx3kraPR_$w@_G7TrBPSy`aDoe zGLH{nWo;mO?&SgS+gkJeSb0Y(afFc zk0W=x-}tor=aoOHOr3l=Gx7>vQ?I?TW=hrVPS@;Fdiww3+DF&!T+8$vy4w@|wD0Fb zKN(7mUd|kS6|bqlm5qRy4u~l!lnE?Q%(tSHbx*pc^Fd4NkJ^6NcDpRo^4wbSFY8;B z{=FOh$J71CKb^_+kFAZQn!3{sJ$f>Cre&X&Y~Z<#fz#=M)1RNr3`|h6p1TdbN@LTl zi#IOUrruHBKu7gLeYyf#azYk@Uc#o_@0)P2X(dDjRN$T_0N? zy65xXudZ7g`d(uF^xf*72Q`gAH`hk4k6u6aYhV41bJx$Mc0ywaqy%w8SWF9xDLK9& zC(?4_p6}a=uXeMTtE_!`kaK&r(Y;sJvU$eDd8${huGv<;ld?Avg6L~}%$YG}p6VN= z*Gp4PgGOcJ34^d&ucpoeqPj%@C&zNbf4vm znyS$O%NGMSJa5s#J($Ey0r@jLz#{Ed*Mt!NK;k4b@-uC9`<|#7EnzaAiO&VWE47Vw`|b$4 z6+8;8tL*{OPRu^+n+P%+TG=kB^+!jqP2~$QNV$3GD7 z@QkgY+%J9oz5RQ;`g-v<(YtSWpm(^p_p%|jf1>X>L~V;@X@~l^#r93~?afl~n^mu7 z_t3X8+I7_uyfLdfUi?ngjLob#8lRklon9@{qzkZMIT(?Hu_gHeI!wMu!OI9#XMX!u zi^RBu9i6Nh*pY+9yU?|L)CK&=d=$uoHZsftiw%l8KyNgE0sB?(sxTRX`GU+iOmaw+ zo|3}hCH`bQEL;=93D=8}un?QYm`(^`)tT?GM83oNCwlvb2ZyK=46%dx4wGe&H+({d zf#E)uA^6{6NJ%_@oD4wxOl09O@Kj-T363WCi-e}hVh#iRrAS=nk4kbZI8Amc3A=KX zKmO(-JR8JB>4gZ!A3HW|E|J-U>%1&XMy^HpBM1`Z82ZuhG{mNu9G{BCWhq~g-u)B3 zdx!gJT#Yry@aaRnHSKf5eQW}3jopvzL?QTJs3~3NB^kSQ3=S127o!4yi;Hlmnc-g! zhQ-LWD1Qd-BaubSO>}&sY#b00KNgoEW&{&LqMStoFggqKokS8|zOcT&iQfI$elx@l z6sj)LvbwU0{Vi2&=LIPWfa|;vjbaswm>=;l|I+y}{xmHTnLiIVkeE!U0Xmcx&nYy7 z%>nnRI0-<=k5QOHrE^2yyKkbeFWY=W?EXSE($Z?9Zb-{0Xp?8`BhGIzh$zKTVPO%S z6eT_h34+NE@@1;wo&uu6vm~n9$PoP<0*DKCuDVXCe_g4oR~j0XmLa9(pwhZuX&O}O zdLQgQjORx-OP%*oWm#$2CeKyZEA?Y~;y$INS846ZCiZotb|2ofm??275;w8Lm$oL} z`?Zu`)D!o!YUqgzQy$S1Yt4dyCLEe~_Sm4g9OIX4e2KcnF=P-8Gx zxbqopuFj8M!Je+sDaj?MSv7;DY8Wh4lm*ZV+E;S(y%zJ`31GJ*mV-cb;1e*WQ~p`f znRLOOsQMdRNb2A~l3jg(m_7!~RtN~oIpLV##_|oT%*h;wjH`^m(gJHIyUIGhp@#K^ zb0fCEs>@qr!^e>#2RY}*FxN|E$&xvO__tljvV!B)kOz+Sk;8m1oVHOAmd|TML(u*smR3fT$(Oas)m}aCK88z&b`7V)$z@hpHa&CMF1B3!H&g`Bf)-d+jU;cehv&$ol(@*y3mW7n>BBL}YuOOUt$62&LsJRHuE8h8e& zsV#=lE9!!=WG{yJ8RsDs&Kdt)VyIwhQm&Sa1g5FF8s>OeGLG z-+S^|%%5$LXu`9-b%~l>Pv!b3i)oTiTprU*bZf|99u77PtWgHdVuJ|_utBv*!w%(t zM6?c5z%)2UnqW_v?40rh1x%}ZiJnNclV73W5`sjDq4yK}&NFLY`qxG{uDp@zp z3$hdz7?O-`ij%^@K*~NNvS}F40(H;gnyNFOOf3xqLuBYYxj!d*bAEDsRKWN%bk0~K zBUy%bCiWSlvY;xk)L|M;C~?sG@_h@4HD7k33@+3LYr0yx(5ATnM+2{}_SayA7q?j?KP7Df=nN1GE-sUj}s6B8Z~=IwcvR-~jPm3=RCVW{S$frV z73+!Y07wX@O(KgVsvSXqmKVcu;r$IV)I+LwOFwBe=zIYC8YL-9=O~7|rnQ~I$HY$K zT+n^D$pgXYY0w9T8r_7-2X)QEd3;+ST*Wi-#A2=}+XOUM25qjq(g5T>Xy{g|I-tNY z1*nYWeRp{!l4|Ty+%+rnsoFj`{*?}ZxTE5!lLH$2+V$5ISCvv;src#@U)58Gt;)IL zBnO15jgsbcN%Q*n-I5Mq$D3wTMXgfXx>4JcuI)+H^xm=DdGX(z`&Z{u1IIwL{DkR= z#X|M6pk7X{y7@-rdL&b|YolsUx@ynuQ^0bo_OFy|7IE&n_apB{*5)&1yYH6u-kw~s zAhOi+{>gVwuAcbrIRYKM3KGHDGPR>8)jb|-%9Z>OT6$+ z+UPu#?mYBqai;Us%IQ>1N806o;B92ddNSUAEm>FJM%VFl7s0<>W0cH)*R@+It$9E2 zZeT5Ndooknw^2HjE*(-_Lr*Q{GAGLJ-v7ytbl0fj+5gC5?k;1w(yra81r4%spw{YX zoZ4xes?nMCjVo>dzTb)HB4E~i-L3e>6kp3zo2A@K^P%d2t7gO1 zkajh!y>X8my> zR%%WwH9IzH0DbSl!!6fM*VAHaWfkgDS@)p6<<|2zpa0;6RXZA0&Tn|y)1G$4*TDR1 z8*a-PUq70R>NvTU-9KvhVZ-f6rul`{vd1S)oV)SqD-gUr@4Mc0DXtTWtMb0fz3R@m zcog7pW+(iXzG$Mp@KaxugJKh(ve#`|YaC_wt9RT;UQa5X8dxDvOQog`t*R+oIYm8M zv*B({yIa?zcip>{wyqz&{=?V*d0?Y$U%G9d;%i;MfK}A)O?z6NqR(omA2GozcRuhn zZ1_6Tz7A^6+3ROP5bbE-s#}ygztY(AsMzXpZgQyUZ=d4m4MF5K4I*bFaYFG`vXLOF zRlnOT!N=b{y#Qz(-ndp&S#!o!JjWGJ`EM{K%O25sfdd;pei_{l9`W<%M=H)*xi35; zX59Y9IWl;*m-~g)akk6yi_)sI9hP5o*yy>}F;;8&#r~=>pXERJY@*Wgl0A-%O&e+65;$hT^O5Z7td{`tN5szly+u8du@i^*5 zbw9w~PlzY8-+6kUv5Kci3Gj~!e~H1#$%Ds8&||niq*~YvWXQvCN&q?z(>XBiI30g7 z9F8+?GFfkfynJ|8lBMYoorEOe5@9GF&5FfxFc`{9*j@ZN*faPJW(&wW?sdTwfl~|w zhQdsZ3PeKEEI&f0Az0d>em~#!0zW5-0^g6*lM+rXHP6&xc?QnMNHU@5Z4(7)Y=@M_@UDJ^Hi+OqLolONS)-1_8w~I{o{)hj5N_ zf#lPc{wtJ%^PG48T0wHqhdnHP;@pg+Rkhj$Vg_6eCMbZ7-r-cmYPgj zt{g{@c>e#!(4(4OS3TsHfRkw?{RYUUow%smjBt=Kt6h=F2#;ikr@0ZwVGn2sVIH=7 z7^?AUPMGHxaSAiUV?^O!0xNfr&R@<#q=t?m&3DUJsm`RU%GW3$2OSt=80O6kB;=gH zTv3>FLWmg5;Vb|1mGZ23KTnxRvQJbAWRL1b`N6}XC8%?-VsyhcP7*)McG7FQgltUs z4vIhrW4>IBspNjq_q^%{u=aY~BJXVgy*Nj9EPZEU3` z&TL)8!|?vqs*Sl5To5`jUodz$ubX!;BBd0wgqX&)ARSPe%i1fu6D5Yl^pM2w#LL;0 zg2S*-kLkQ^cEu0VVPI{E)4F83PY^{C-O=;>2y_Bkku-A`CGt69jvKn`ayM*S2F{_{ zHFpPfp1_DiJcZ`T!ypxLR_mx^V7)NR2*gB(+3@PUB0@EXO3F&m57yG!j7cHZ5lpY= z!{m(<>Q?PKD+&G&aQws@r-V=h7OHt>%I(bZfM0 zr(~yy;nvn)|P(0cl{4G(sd|H^4 zLSS?>Tc^n6$EFpTfU`Yg=vi&l$kDGGu4OOnXHVHxWNJW^lDl1*K@)qZ!9=Fu}XL8CAFuh5F<9|PWdn|oTtc`H{h-L2fU z-j*rvSSi{ps;(|mJihgpe)Pr<-?-N{^rus6?SDG|36wj0c;Kl`HC{?xzLL5mq=d;- z{nVxn(Il_8rgnaNvzT5Wqt>o9Z#wAN$(7fyjck_CvrCIFrROrPe8+mprkkEUTzPY< zWoWaUUc6j+ORDw2W(B?Yv{aS!T*Xoy+^nXT8kXwdW-Yzcv6uau_4Lxfl{coE1~(h& zr78QegI;(PpQ`KKY^IkMNaqT(ODfH6O51@)4qKgfvz0y#a`jCQn*0hspzQQRFmEFD zq`6aR=_0%4j&n*&htl4wwCsCSUj`wb*OMPmc6Jz(`^osYV?k-@`2&)_Nw&~J)C*T; z<`1JDR$4lZ)W2;yW2!IPe8Y@3Y?`^2BW8tfQ+DiDnxCikl@_CY-t4!N&-kV^Pb>UR zmT7QHCZ&1#tJDUXo5~)YwVX1Ox^v3xaB2orOvv0-geRtB?8Xcw18Nkf!2#Rn(j<*X zUa}x{E;k-@;9ABB?`2bXaZ66Co-&u}FiZ!nH^Qe7F?p`IpUx12_e0Q}Ad_ zPS-*G_*f3cFJwp%t+8^wR!Q#TY%l1gn{XyNX)1IE;E(nrzEaPbzZ+^>m5~ULSFnpp_&pMAAvsF;!4I}E zXMYz%;x(^Xt#~+N&~bC-7cOFxLuKX!Y-O+k!1tMcE5u+1xN-%}xN=1o`HbaixU0dM zfQHEuWCe22fnJu^)lR6U%ZZ{rO#VnfL|c^dy%Gyj9mJsa!zw^&W%_ZMqF92q=tOzj zrLMMxt~QZxJ2l*PcDQXk(Wt@2a4&+=ruPEBt1Y^VAYQ*)bpi{`{;-E?*AuWEP9DHa zBY1Q%Buq(aDS7q=qY--!}5J%|2P;u_@J#c1}|i1Ag&~ z8rVN00Ys{1F{aw4LXr^H9B7FL5Rl3cRJDv%op4z;FAAO)-K%_9~jzRh`ZE;+Dc8Vn z@4IV&hN8au8SVq0y!@$f&voj4MGg62I%>a+(z%R(a>RACgZnG*NF{EcbvTZBEuZyO zA1k%|wA6-Y917f_|0oM-i%|BUQO1Rhs5DWxVXF&fM zlK_5mAn>iY5Xycj4g|!=6eSs-7@4>@9ymL4UUi+nFnaF7=)@&@leLaMrKv+HlsVo%L&{GS0TQi+^K1 N>@)$5q>xqke*?(rT@L^N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8a9a7949912b1ad4d5deccdeb51ca83d33a1fa5b GIT binary patch literal 397 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2c441dea6d4f160d52a8bd7dbeb8660b489646b4 GIT binary patch literal 54391 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 literal 0 HcmV?d00001 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 + +