From ebdc2ee30411b2cfb2ea32802dfd821bd6a0211a Mon Sep 17 00:00:00 2001 From: Madanil Date: Mon, 19 Oct 2026 08:07:51 +0700 Subject: [PATCH] [ADD] iitial commit gmn fresh service --- gmn_freshservice/__init__.py | 4 + gmn_freshservice/__manifest__.py | 34 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 257 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_technical_design_document_v01.md | 360 +++++++ gmn_freshservice/models/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 367 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 .../res_config_settings.cpython-312.pyc | Bin 0 -> 3349 bytes .../res_config_settings.cpython-314.pyc | Bin 0 -> 3222 bytes ...mn_freshservice_ticket_conversation_v01.py | 36 + .../gmn_freshservice_ticket_stage_log_v01.py | 363 +++++++ .../models/gmn_freshservice_ticket_v01.py | 965 ++++++++++++++++++ .../models/gmn_res_config_settings_v01.py | 71 ++ .../security/gmn_ir_model_access_v01.csv | 5 + gmn_freshservice/services/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 217 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 .../services/gmn_freshservice_client_v01.py | 637 ++++++++++++ ...reshservice_ticket_stage_log_views_v01.xml | 106 ++ .../gmn_freshservice_ticket_views_v01.xml | 334 ++++++ gmn_freshservice/views/gmn_menu_views_v01.xml | 49 + .../gmn_res_config_settings_views_v01.xml | 47 + gmn_freshservice/wizard/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 233 bytes ...e_ticket_bottleneck_wizard.cpython-312.pyc | Bin 0 -> 46566 bytes ...eshservice_ticket_bottleneck_wizard_v01.py | 883 ++++++++++++++++ ...ice_ticket_bottleneck_wizard_views_v01.xml | 85 ++ 38 files changed, 4111 insertions(+) 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/gmn_technical_design_document_v01.md 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__/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_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_ir_model_access_v01.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/gmn_freshservice_client_v01.py 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/gmn_freshservice_ticket_bottleneck_wizard_v01.py create mode 100644 gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml diff --git a/gmn_freshservice/__init__.py b/gmn_freshservice/__init__.py new file mode 100644 index 0000000..1c5a144 --- /dev/null +++ b/gmn_freshservice/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +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..158ba9d --- /dev/null +++ b/gmn_freshservice/__manifest__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Freshservice Ticket Audit Trail & Time Metrics', + 'version': '19.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_ir_model_access_v01.csv', + 'data/gmn_demo_data_v01.xml', + 'views/gmn_res_config_settings_views_v01.xml', + 'views/gmn_freshservice_ticket_stage_log_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..2202b1252c1dcd26148654f92524642308b8ff98 GIT binary patch literal 257 zcmX@j%ge<81kuaRW<>(&#~=<2FhLogHGqui3@HpLj5!Rsj8Tk?AT|?_%@oB1W-|lX z%u&pY43#XJtS=dX$~75pvE}Bcq~;U@SscZwMP-@EsUS96d1h5&QHq}?%Poc?W}wO< z77)P-Bvvwf29gZF98#=e@>BBj6$~xGbWD0~UOdpS(wx+ow4&7F45)GOC7H?DsU`7= zr74*uG4b)4d6^~g@p=W7w>WGd%I%6kz5}_Zm=8#NU}j`we99nlmqF|vgGdnvPzV4l Cd_gS$ literal 0 HcmV?d00001 diff --git a/gmn_freshservice/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bab5240b1a62c1bdcaff99c489ffdb611ee7826 GIT binary patch literal 223 zcmdPq<)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/gmn_technical_design_document_v01.md b/gmn_freshservice/gmn_technical_design_document_v01.md new file mode 100644 index 0000000..4426be5 --- /dev/null +++ b/gmn_freshservice/gmn_technical_design_document_v01.md @@ -0,0 +1,360 @@ +# TECHNICAL DESIGN DOCUMENT (TDD) +## Integrasi Freshservice API v2 & Audit Trail SLA pada Odoo 19 + +--- + +### Informasi Dokumen +* **Nama Modul**: `freshservice_ticket_audit` +* **Target Sistem**: Odoo 19.0 Community / Enterprise +* **Sistem Eksternal**: Freshservice API v2 (`https://servicedesk.lyman.co.id`) +* **Versi Dokumen**: 1.0.0 +* **Status**: Approved & Implemented +* **Tanggal**: 13 Oktober 2026 + +--- + +## 1. Pendahuluan & Latar Belakang + +### 1.1 Tujuan Sistem +Modul `freshservice_ticket_audit` dirancang untuk mengintegrasikan data tiket dari Freshservice ke dalam Odoo 19 secara real-time dan efisien. Fokus utama dari modul ini adalah: +1. **Audit Trail Real-Time**: Merekam setiap perubahan status tiket, perpindahan penanganan antara tim IT (*Waiting on Us*) dan pengguna (*Waiting on User*), hingga tiket selesai. +2. **Perhitungan Metrik SLA Berbasis Hari Kalender**: Menghitung durasi penanganan secara adil berdasarkan pergantian tanggal kalender (bukan jam kerja murni), sehingga mempermudah rekonsiliasi laporan SLA bulanan. +3. **Pemisahan Aktor & Hak Akses**: Data tiket murni merefleksikan aktor Freshservice (Pemohon, Agen IT, Departemen) tanpa ketergantungan pada akun internal user Odoo (`res.users`). +4. **Performa Tinggi & Zero-Latency**: Menerapkan strategi **Two-Tier Synchronization** agar pembukaan daftar tiket tidak mengalami *freeze* atau *timeout*. + +--- + +## 2. Arsitektur Integrasi & Strategi Sinkronisasi + +### 2.1 Arsitektur Tingkat Tinggi (High-Level Architecture) + +```mermaid +flowchart TB + subgraph Freshservice [Freshservice API v2 Cloud] + FS_TICKETS["GET /api/v2/tickets?include=requester,stats"] + FS_DETAIL["GET /api/v2/tickets/{id}?include=requester,stats"] + FS_ACTIVITIES["GET /api/v2/tickets/{id}/activities"] + FS_CONVERSATIONS["GET /api/v2/tickets/{id}/conversations"] + FS_AGENTS["GET /api/v2/agents/{id}"] + FS_DEPTS["GET /api/v2/departments/{id}"] + end + + subgraph Adapter [Integration Layer: FreshserviceClient] + HTTP_CLIENT[HTTP Client with Basic Auth] + CACHE_AGENT[In-Memory Cache: Agents] + CACHE_DEPT[In-Memory Cache: Departments] + DATA_PARSER[JSON Parser & Normalizer] + end + + subgraph Odoo_Core [Odoo 19 ORM & Business Logic] + LOCK_ENGINE[Row Lock: FOR UPDATE NOWAIT] + SLA_ENGINE[Calendar Day SLA Engine] + M_TICKET[(freshservice.ticket)] + M_STAGE[(freshservice.ticket.stage.log)] + M_CONV[(freshservice.ticket.conversation)] + end + + FS_TICKETS -->|Batch Fetch Max 200| HTTP_CLIENT + FS_DETAIL -->|Single Fetch on Demand| HTTP_CLIENT + FS_ACTIVITIES --> HTTP_CLIENT + FS_CONVERSATIONS --> HTTP_CLIENT + FS_AGENTS <--> CACHE_AGENT + FS_DEPTS <--> CACHE_DEPT + CACHE_AGENT <--> HTTP_CLIENT + CACHE_DEPT <--> HTTP_CLIENT + + HTTP_CLIENT --> DATA_PARSER + DATA_PARSER --> LOCK_ENGINE + LOCK_ENGINE --> M_TICKET + DATA_PARSER --> SLA_ENGINE + SLA_ENGINE --> M_STAGE + DATA_PARSER --> M_CONV +``` + +--- + +### 2.2 Pola Sinkronisasi: Two-Tier Synchronization + +Untuk menjamin skalabilitas dan menghindari *timeout*, sinkronisasi dibagi menjadi 2 tahap: + +1. **Tier 1: Lightweight Batch Sync (Daftar Tiket Terbaru)** + * Dijalankan otomatis saat user membuka menu **Tiket Audit** (`web_search_read`). + * Hanya mengambil ringkasan **200 tiket terbaru** (menggunakan paginasi 100 tiket per request). + * Parameter query: `include=requester,stats` untuk mengambil ID, Subjek, Status, Prioritas, Nama Pemohon, Email, dan Waktu Dibuat. + * **Tidak memanggil activities dan conversations** pada tier ini untuk menghemat bandwidth dan waktu eksekusi. + +2. **Tier 2: On-Demand Deep Sync (Detail Lengkap Tiket)** + * Dijalankan secara otomatis (*lazy loading*) saat form tiket tertentu dibuka (`web_read`) atau saat tombol **"Sinkronkan Ulang dari Freshservice"** diklik. + * Mengambil paket lengkap: + * Status & detail terkini tiket. + * Log seluruh riwayat aktivitas (`/activities`). + * Log seluruh percakapan dan balasan email (`/conversations`). + * Resolusi nama Agen IT dan Departemen via cache. + +--- + +### 2.3 Diagram Alur Interaksi (Sequence Diagrams) + +#### A. Alur Tier 1: Membuka List Tiket (Auto-Sync Ringan) + +```mermaid +sequenceDiagram + autonumber + actor User as Pengguna Odoo + participant Web as Odoo Web Client (JS) + participant Model as freshservice.ticket (ORM) + participant Client as FreshserviceClient + participant FS as Freshservice API v2 + + User->>Web: Buka Menu "Tiket Freshservice" + Web->>Model: web_search_read(domain, fields, offset, limit=200) + Model->>Model: _auto_sync_recent_tickets(limit=200) + Note over Model: Cek throttle interval (misal 60 detik) + Model->>Client: fetch_recent_tickets(limit=200) + Client->>FS: GET /api/v2/tickets?order_by=created_at&order_type=desc&per_page=100&page=1&include=requester,stats + FS-->>Client: 200 OK (Array 100 Tiket + Requester + Stats) + Client->>FS: GET /api/v2/tickets?order_by=created_at&order_type=desc&per_page=100&page=2&include=requester,stats + FS-->>Client: 200 OK (Array 100 Tiket + Requester + Stats) + Client-->>Model: Return List 200 Parsed Tickets + Model->>Model: Batch Insert/Update ke freshservice_ticket + Model-->>Web: Return Data 200 Tiket + Web-->>User: Render Tree View Tiket +``` + +#### B. Alur Tier 2: Membuka Form Tiket (On-Demand Deep Sync) + +```mermaid +sequenceDiagram + autonumber + actor User as Pengguna Odoo + participant Web as Odoo Web Client (JS) + participant Model as freshservice.ticket (ORM) + participant Client as FreshserviceClient + participant FS as Freshservice API v2 + + User->>Web: Klik Baris Tiket (Buka Form View) + Web->>Model: web_read(id, specification) + alt is_detail_synced == False + Model->>Model: Execute Row Lock (SELECT ... FOR UPDATE NOWAIT) + Model->>Client: fetch_ticket_full_data(external_ticket_id) + par Ambil Detail Tiket + Client->>FS: GET /api/v2/tickets/{id}?include=requester,stats + FS-->>Client: Ticket JSON + and Ambil Activities + Client->>FS: GET /api/v2/tickets/{id}/activities + FS-->>Client: Activities Array + and Ambil Percakapan + Client->>FS: GET /api/v2/tickets/{id}/conversations + FS-->>Client: Conversations Array + end + Client->>Client: Resolve Agent & Dept Names (Cache Checked) + Client-->>Model: Full Payload Package + Model->>Model: Update Header Tiket (is_detail_synced = True) + Model->>Model: Rekonstruksi freshservice.ticket.stage.log + Model->>Model: Hitung Durasi SLA Hari Kalender + Model->>Model: Simpan freshservice.ticket.conversation + end + Model-->>Web: Return Record Values (Header, Logs, Conversations) + Web-->>User: Tampilkan Form View Lengkap dengan Tab SLA & Audit Trail +``` + +--- + +## 3. Spesifikasi Endpoint Freshservice API v2 & Konfigurasi + +### 3.1 Parameter Konfigurasi Sistem (UI Settings) + +| Parameter Key | Field di Settings | Default | Deskripsi | +|---|---|---|---| +| `freshservice_ticket_audit.freshservice_url` | Freshservice URL | `https://ptglobalmultimedianetwork.freshservice.com` | Base URL portal Freshservice perusahaan. | +| `freshservice_ticket_audit.freshservice_api_key` | Freshservice API Key | - | API Key pengguna dengan hak baca tiket. | +| `freshservice_ticket_audit.freshservice_sync_limit` | Limit Sinkronisasi Tiket | `200` | **Batas jumlah tiket terbaru yang ditarik saat membuka halaman daftar tiket (dapat diubah dinamis via UI Konfigurasi).** | +| `freshservice_ticket_audit.freshservice_use_mock` | Gunakan Mock Data Engine | `False` | Switch antara mode simulasi dan Live API. | + +### 3.2 Spesifikasi Endpoint Freshservice API v2 + +| No | Endpoint | HTTP Method | Header & Autentikasi | Parameter Wajib | Kegunaan | +|---|---|:---:|---|---|---| +| 1 | `/api/v2/tickets` | `GET` | `Authorization: Basic {API_KEY:X}`
`Accept: application/json` | `order_by=created_at`
`order_type=desc`
`per_page=100`
`page={1..N}`
`include=requester,stats` | Penarikan tiket terbaru sesuai limit konfigurasi (`freshservice_sync_limit`). | +| 2 | `/api/v2/tickets/{id}` | `GET` | Basic Auth | `include=requester,stats` | Pengambilan detail spesifik satu tiket. | +| 3 | `/api/v2/tickets/{id}/activities` | `GET` | Basic Auth | - | Rekonstruksi log audit trail dan tahapan status. | +| 4 | `/api/v2/tickets/{id}/conversations` | `GET` | Basic Auth | - | Pengambilan log chat/balasan email pada tiket. | +| 5 | `/api/v2/agents/{id}` | `GET` | Basic Auth | - | Resolusi ID agen menjadi nama lengkap (`first_name` + `last_name`). | +| 6 | `/api/v2/departments/{id}` | `GET` | Basic Auth | - | Resolusi ID departemen menjadi nama departemen. | + +--- + +## 4. Pemetaan Data (Data Field Mapping Dictionary) + +### 4.1 Entitas Utama: `freshservice.ticket` + +| Nama Field Odoo | Tipe Odoo | Field Asal Freshservice v2 | Tipe Asal | Logika Transformasi & Keterangan | +|---|---|---|---|---| +| `external_ticket_id` | `Char` | `ticket.id` | `Integer` | Konversi integer ke string, diindeks untuk pencarian cepat. | +| `name` | `Char` | `ticket.subject` | `String` | Subjek/judul tiket. | +| `description` | `Text` | `ticket.description_text` | `String` | Teks isi deskripsi tanpa format HTML. | +| `status` | `Selection` | `ticket.status` | `Integer` | Dipetakan via `STATUS_MAP` (lihat tabel 4.4). | +| `priority` | `Selection` | `ticket.priority` | `Integer` | Dipetakan via `PRIORITY_MAP` (lihat tabel 4.5). | +| `requester_name` | `Char` | `ticket.requester.name` | `String` | Diambil dari objek embedded `requester`. | +| `requester_email` | `Char` | `ticket.requester.email` | `String` | Email pemohon dari objek embedded `requester`. | +| `responder_name` | `Char` | `ticket.responder_id` | `Integer` | Dicari via `/api/v2/agents/{responder_id}` $\rightarrow$ nama lengkap agen IT. Di-cache. | +| `department_name` | `Char` | `ticket.department_id` | `Integer` | Dicari via `/api/v2/departments/{department_id}` $\rightarrow$ nama departemen. Di-cache. | +| `ticket_created_at` | `Datetime` | `ticket.created_at` | `ISO 8601` | Diparsing dari UTC ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`) ke Odoo Datetime. | +| `due_by` | `Datetime` | `ticket.due_by` | `ISO 8601` | Batas waktu SLA penyelesaian tiket. | +| `first_responded_at` | `Datetime` | `ticket.stats.first_responded_at` | `ISO 8601` | Diambil dari objek embedded `stats`. | +| `resolved_at` | `Datetime` | `ticket.stats.resolved_at` | `ISO 8601` | Waktu selesai dari objek embedded `stats`. | +| `first_responder_name` | `Char` | Log Activities / Responder | `String` | Nama agen yang pertama kali merespons tiket. | +| `is_mock_data` | `Boolean` | Parameter Sistem | `Boolean` | `True` jika ditarik dari Mock Engine, `False` jika Live API. | +| `is_detail_synced` | `Boolean` | Internal System Flag | `Boolean` | Penanda apakah log aktivitas & percakapan sudah ditarik. | +| `last_detail_sync` | `Datetime` | Internal System Flag | `Datetime` | Timestamp terakhir kali detail disinkronkan. | +| `current_responsibility` | `Selection` | Dihitung otomatis | - | `'customer'` jika pending; `'closed'` jika resolved/closed; `'internal'` selainnya. | +| `first_response_time_days` | `Integer` | Dihitung otomatis | - | Selisih hari kalender dari `ticket_created_at` ke `first_responded_at`. | +| `first_response_time_display` | `Char` | Dihitung otomatis | - | Format representasi: `"X Hari"` atau `"Belum Ada Respon"`. | +| `total_agent_time_days` | `Integer` | Dihitung otomatis | - | Akumulasi hari kalender tanggung jawab `internal` (Waiting on Us). | +| `total_agent_time_display` | `Char` | Dihitung otomatis | - | Format representasi: `"X Hari"`. | +| `total_customer_time_days` | `Integer` | Dihitung otomatis | - | Akumulasi hari kalender tanggung jawab `customer` (Waiting on User). | +| `total_customer_time_display` | `Char` | Dihitung otomatis | - | Format representasi: `"X Hari"`. | +| `resolution_time_days` | `Integer` | Dihitung otomatis | - | Selisih hari kalender dari `ticket_created_at` ke `resolved_at`. | +| `resolution_time_display` | `Char` | Dihitung otomatis | - | Format representasi: `"X Hari"` atau `"Masih Berjalan"`. | + +--- + +### 4.2 Entitas Audit Trail Status: `freshservice.ticket.stage.log` + +Model ini dihasilkan dari rekonsiliasi data riwayat `/api/v2/tickets/{id}/activities`. + +| Nama Field Odoo | Tipe Odoo | Field Asal Freshservice v2 | Tipe Asal | Logika Transformasi & Keterangan | +|---|---|---|---|---| +| `ticket_id` | `Many2one` | `ticket.id` | `Integer` | Relasi Many2one ke `freshservice.ticket` (ondelete='cascade'). | +| `stage_name` | `Char` | `activity.content` / Status | `String` | Nama tahapan status (misal: *Open*, *Pending Customer*, *Resolved*). | +| `responsibility` | `Selection` | Evaluasi Status & Aktivitas | `String` | `'customer'` jika menunggu user/pending; `'internal'` jika di pihak agen/IT. | +| `actor_name` | `Char` | `activity.actor.name` | `String` | Nama pihak yang melakukan aksi/perubahan status. | +| `actor_type` | `Selection` | `activity.actor.is_agent` / ID | - | `'Agent'` jika `is_agent=True`; `'System'` jika actor=0/workflow; `'Requester'` selainnya. | +| `activity_content` | `Text` | `activity.content` | `String` | Deskripsi detail log atau perubahan field pada Freshservice. | +| `start_date` | `Datetime` | `activity.created_at` | `ISO 8601` | Waktu dimulainya tahapan status ini. | +| `end_date` | `Datetime` | Aktivitas berikutnya (`created_at`) | `ISO 8601` | Waktu berakhirnya tahapan status ini (False jika status aktif). | +| `is_active` | `Boolean` | Evaluasi `end_date` | - | `True` jika merupakan tahapan terakhir yang sedang berlangsung. | +| `duration_days` | `Integer` | Dihitung otomatis | - | Durasi hari kalender dari `start_date` sampai `end_date` (atau saat ini). | +| `duration_hours` | `Float` | Dihitung otomatis | - | Durasi jam teknis (disimpan di database, disembunyikan di UI). | +| `duration_display` | `Char` | Dihitung otomatis | - | Contoh format tampilan: `"1 Hari"` atau `"2 Hari (Aktif)"`. | + +--- + +### 4.3 Entitas Percakapan: `freshservice.ticket.conversation` + +Model ini dihasilkan dari data `/api/v2/tickets/{id}/conversations`. + +| Nama Field Odoo | Tipe Odoo | Field Asal Freshservice v2 | Tipe Asal | Logika Transformasi & Keterangan | +|---|---|---|---|---| +| `ticket_id` | `Many2one` | `ticket.id` | `Integer` | Relasi Many2one ke `freshservice.ticket`. | +| `external_id` | `Char` | `conversation.id` | `Integer` | ID unik percakapan pada Freshservice. | +| `user_name` | `Char` | `conversation.user_name` / User | `String` | Nama pengirim pesan/catatan. | +| `user_id_external` | `Char` | `conversation.user_id` | `Integer` | ID pengguna pengirim pesan pada Freshservice. | +| `incoming` | `Boolean` | `conversation.incoming` | `Boolean` | `True` jika pesan dari Pemohon (Customer); `False` jika balasan dari Agen IT. | +| `sender_type` | `Selection` | Evaluasi `incoming` | - | `'customer'` jika `incoming=True`; `'agent'` jika `incoming=False`. | +| `body_text` | `Text` | `conversation.body_text` | `String` | Isi teks pesan (plain text / stripped HTML). | +| `created_at` | `Datetime` | `conversation.created_at` | `ISO 8601` | Waktu pesan dikirimkan. | + +--- + +### 4.4 Kamus Konversi Status (`STATUS_MAP`) + +| Kode Freshservice | Deskripsi Freshservice | Value Odoo (`status`) | Label Odoo | Tanggung Jawab Default | +|:---:|---|---|---|:---:| +| `2` | Open | `'open'` | Open | Internal (*Waiting on Us*) | +| `3` | Pending | `'pending'` | Pending User | Customer (*Waiting on User*) | +| `4` | Resolved | `'resolved'` | Resolved | Selesai (*Closed*) | +| `5` | Closed | `'closed'` | Closed | Selesai (*Closed*) | + +--- + +### 4.5 Kamus Konversi Prioritas (`PRIORITY_MAP`) + +| Kode Freshservice | Deskripsi Freshservice | Value Odoo (`priority`) | Label Odoo | +|:---:|---|---|---| +| `1` | Low | `'low'` | Low | +| `2` | Medium | `'medium'` | Medium | +| `3` | High | `'high'` | High | +| `4` | Urgent | `'urgent'` | Urgent | + +--- + +## 5. Logika Perhitungan SLA Berbasis Hari Kalender + +### 5.1 Definisi & Formulasi +Perhitungan SLA tidak menggunakan selisih jam murni dibagi 24, melainkan menggunakan **selisih tanggal kalender** (*Calendar Days*) berdasarkan zona waktu pengguna (default `Asia/Jakarta`, UTC+7): + +$$\text{Tanggal Mulai} = \text{Date}(\text{ConvertTZ}(\text{StartDatetime}, \text{UserTZ}))$$ + +$$\text{Tanggal Selesai} = \text{Date}(\text{ConvertTZ}(\text{EndDatetime}, \text{UserTZ}))$$ + +$$\text{Durasi Hari Kalender} = \max(0, \text{Tanggal Selesai} - \text{Tanggal Mulai})$$ + +### 5.2 Contoh Kasus Perhitungan +* **Kasus 1 (Hari yang Sama)**: + * Tiket dibuat: `13 Okt 2026 09:00 WIB` + * First Response: `13 Okt 2026 15:30 WIB` + * Perhitungan: $13\text{ Okt} - 13\text{ Okt} = \mathbf{0\text{ Hari}}$. +* **Kasus 2 (Ganti Hari / Besoknya)**: + * Tiket dibuat: `13 Okt 2026 16:00 WIB` + * First Response: `14 Okt 2026 08:30 WIB` + * Perhitungan: $14\text{ Okt} - 13\text{ Okt} = \mathbf{1\text{ Hari}}$. +* **Kasus 3 (Pending Berhari-hari)**: + * Status diubah ke *Waiting on User*: `14 Okt 2026` + * User membalas kembali: `17 Okt 2026` + * Perhitungan: $17\text{ Okt} - 14\text{ Okt} = \mathbf{3\text{ Hari}}$. + +--- + +## 6. Penanganan Masalah & Optimasi Kinerja + +1. **Peniadaan Ketergantungan User Odoo**: + * Field `user_id` ("Petugas Odoo") dan `user_ids` telah dihapus dari antarmuka pengguna (Form View, List View, Search View). + * Nilai `default=lambda self: self.env.user` telah dinonaktifkan sehingga tidak ada penugasan otomatis ke user sistem (seperti `OdooBot`). +2. **In-Memory Caching untuk Master Data**: + * API Freshservice tidak menyertakan nama agen/departemen secara langsung pada objek tiket. + * `FreshserviceClient` mengimplementasikan dictionary cache `_agent_cache` dan `_dept_cache` yang memangkas ratusan panggilan HTTP redundan saat memproses 200 tiket sekaligus. +3. **Database Concurrency Lock**: + * Fungsi `action_sync_from_freshservice` menerapkan query SQL: + ```sql + SELECT id FROM freshservice_ticket WHERE id = %s FOR UPDATE NOWAIT; + ``` + Hal ini mencegah konflik penulisan/duplikasi data jika dua pengguna membuka tiket yang sama secara bersamaan. +4. **Paginasi Cepat Freshservice API**: + * Freshservice API membatasi maksimal 100 tiket per panggilan HTTP (`per_page=100`). + * Sinkronisasi awal melakukan *looping* otomatis (`page=1` dan `page=2`) untuk memperoleh tepat 200 tiket terbaru tanpa membebani server. + +--- + +## 7. Status Pengujian & Verifikasi + +Modul telah diuji langsung pada lingkungan Odoo 19 dengan database PostgreSQL `odoo19`: +* **Pengujian Penarikan 200 Tiket**: Berhasil mengekstrak tiket terbaru secara cepat. +* **Pengujian Integritas Data**: Field Pemohon, Email, Agen IT, dan Departemen terverifikasi terisi lengkap. +* **Pengujian Tampilan UI**: Field durasi jam berhasil disembunyikan dan seluruh metrik SLA tampil dalam satuan Hari Kalender. + +--- + +## 7. Fitur Wizard Laporan Analisis Bottleneck (Excel / .xlsx) + +### 7.1 Deskripsi & Tujuan +Fitur ini menyediakan pop-up wizard (`freshservice.ticket.bottleneck.wizard`) yang mengekspor hasil analisis bottleneck tiket ke dalam file spreadsheet Excel (`.xlsx`) berstandar presentasi manajemen/meeting. + +Laporan ini membedah faktor keterlambatan tiket: +1. **Pemisahan Tanggung Jawab**: Apakah tiket tertahan lama di sisi Internal IT (*Waiting on Us*) atau di sisi User (*Waiting on Customer*). +2. **Ranking Bottleneck Tahapan**: Mengurutkan tahapan/status tiket dari yang durasinya paling lama (*slowest stage*) beserta penanggung jawab dan kontribusi persentasenya. +3. **Auto-Fetch On-Demand**: Bila tiket yang dipilih (misal tiket `#992`) belum tersinkron rincian stage log dan percakapannya, wizard otomatis melakukan fetch langsung ke API Freshservice sebelum mengompilasi workbook Excel. + +### 7.2 Struktur Sheet Workbook Excel +1. **Sheet 1: `Ringkasan & Bottleneck KPI`**: + - Metadata Tiket (ID, Subjek, Pemohon, Departemen, Agen IT, Status, Tanggal Dibuat, Tanggal Selesai). + - Kartu Metrik KPI (Total Hari, Hari di IT, Hari di User, First Response). + - Tabel Ranking Bottleneck per Tahapan (diurutkan dari stage terlama ke tercepat). + - Banner Kesimpulan Bottleneck Utama untuk bahan evaluasi meeting. +2. **Sheet 2: `Audit Trail & Kronologi`**: + - Rincian kronologis transisi status, waktu mulai, waktu selesai, durasi hari & jam, penanggung jawab, dan deskripsi aktivitas. +3. **Sheet 3: `Percakapan & Respon`**: + - Riwayat catatan publik (*Public Reply*) dan internal (*Private Note*), pengirim, dan cuplikan pesan. +4. **Mode Multi-Tiket / Ranking**: + - Menghasilkan tabel ranking tiket terlama dengan ringkasan *primary bottleneck stage* masing-masing tiket. + diff --git a/gmn_freshservice/models/__init__.py b/gmn_freshservice/models/__init__.py new file mode 100644 index 0000000..b941012 --- /dev/null +++ b/gmn_freshservice/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from . import gmn_res_config_settings_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..d859222dd10529e5fd13696c11370fcd3e8d5503 GIT binary patch literal 367 zcmZvXze)r#5XL8)-NQdzA=biOxd;b>jUu*o_W(;^ST?yex|>LnJGdv&SMUjZ2z`Qu zvk?osgX^rEtlI4q-~2Ls^9^|&j}L%q{`7YDLj5B?{ww)|-8sP{NRX_7mKBUePKc8t z>6sJrg7=&fr$x$Oo#mr1MyomNj-B#YS+meJdKH2;wWsmPg66kgxs|RI23_5&fIgJ9 z!p7EQm<$;xXQiAkgSIAS4|wKz)`8=6+@(>nC?k?**pb5bjl2|AT01$r=)0&}gRPaz zMu~wuESFLTK?c=$fvAlRh_i=vw|Disb?pu*H-6V2k!(3*>=ULNINiYP9j0!drav+! BYCiw~ 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 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 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__/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 zcma)8Uu+b|8Q;C#`-jio^ZBsP*x)s`V_$;Lf%FfMgw)tT0LP7N1I~%+G`<<%?cLtq zWo8%S#crY~59BG$6QDrhF%>-Yr4?1HRxK}7Rn;>@@^(ulOMR$#a|)HJ^rheI-CF2k@R_Iq_n52OP=;e>$Yj|J=}Vxg_!Z0mbls@2n>WhohW%o|C`4V225 z=*$f)YDCA(l~6@Xif;G}BlH-hWjdYRav=zTdgIA&p>jnaLR!Q^+K0uo(y!!tx#ev-JV#3q}TT@J{HquQop&^u|S-z3~L`*GMB^~8e zllxL3hy_F~br~5s6;g(%R#Y*RYEwWAAyX}p(t=e)Y98sRgbY>J7&!3UjMVd>9(eWD zWGX3d#X*5mf-C2VUa=D=ECUr`<~!wVQJn-d>M@H8+Vwsiz|Ebv@POETMO5X?$98{26Ayl{2`p&6{=~^l& zZHuE7;>e0nG=4#-a2`L2086e2D?(wrwLa>0{VQ^NolW&F?{iUPKF|%Z&`_FfM(n#? z_qZyqh^)orD|$aHH7p;A;5XFh-}=%PEMq?g7UK}-=ye&lT$g4!is$G;lkdou@9+!8 zopU%BpEl}w%-@Ao{lTM<7OaP)?6#kV5i_B*kLQmo1=a^ZQJ(oq}%7ARf> zalF%;-+4o7j$~Ulx}NU`R!x8<*1)#){am;qZ$&vEn9tN|P@ui5{%yXy&92706@Oz6 z%zw5wABlL^s{hCSC2>i(D7_~viOC*&_)I}li)DipP3;1(im64Sa?v8RiCC`SievU5 z$XHW1unME1x=_|h9^A09k?XhHHJU0a>N9u$?ygQ|i8yGl*D!>gLNW?7A03eX30X1HO;F)F63XdUmZuGlB zW@WR4vExbE^_!Zh6E|3bHp&rKHkT`eD<(PxBAR9@a9f8ZjgdB{{dFzb>po(el z8UraEyKrEvri$z>U^U2fj=Z`5F+cYp<}$L(yc^6Dgb8JCkjsDBGHC?NcKd0*XMpD@ zp?Q!Fz26>fRJm5>tp%PW*h~*#lScBeMr>a)Wp|!HIiznJKl1k6+`FnRB@e51&m5rz zgyDD)211&LV7-IFw8QFjGUDza2D2#101!9YB#Ur?xdF=Wq6+1gzq_;9T-Qc7-qc;c zTig&O?^}eK@UF-a(`{iUGA)+L!dZb$BNt6KeC#4mA{-%Cf>n|MS7t1og`<~C04A5z8>EwJ9nC+L<`Py^mJh%fC{N~Rz zZuwMY*&V2z^^6IaGfhrlut7W`*x#M`=BuGjk#C*W2gunQpsgPY8==Uj$|uT~p>9X% zSXTzul)(qe%U}4`+YYR?9r*cc58J+fS^BFIdlc)b_8xs0o2W)6Hd>VRmZ1kNLmP=# zoUYN0-6^N9?DP&fdj`G=`+H(v1;g>SO(ERcw%I8}aQb0vsv4Qvlw;vZ z(TOM4Z=|+dB5ZS>|f^pIDh|CRXz8xbG{m#|IZ^O@h?FH zd>rwfL%$igi+=sm-Ndu#|GO!1>(`8D@%Q`2BNKasKO7o@^4^~Cq$J%N?wU9v-8D1Ih9fl{C;UhoUjEBKjZm!SAqaFoFh-`?oj`;!Y- zE`4^%QTmn>_mmUua9Wq1LaM>svl$U~zjF2X&FO2?p83>9D+AkZuysFa8}Uzy^iTs$GVIEn zHk3ajywE9s2Dl3EOpyi~na6fI$W3i?6d8<*KNoqu7TG~Q-Sf($bwh8|sCR_E2~GZl rV8c+rzKP;Lg~2sp@bALV-vs5UJT8ip;?=-7&-6`tiT|0Ie_QItgenJY`QO~)c(*@YduO(I*4C0ka7wJfQD?8TTIkt;5j z%+An_Hi!6D=M*597`enp=hmZ(76_2~-~t7@h7D-3bx{L76uk+s&<5?LZ}?-8sa=AY zee-7K{mi`Yn|UYN*Ms1%lQK%2nAc6R}VqJ5Z_J21QjvgtpF2;P!*zO(oO$Lq;pYI zhWLD{s7k8;Ogom~TW1E;pc*>kYp!G=m=}Me#jx6=_No!J?~K%%+oMK@5$jcD7Fk2A zPmMvg{a(LH=*6nJr5CRg#&okt?E#C>Y`&q((zY`8A!tjG`yD+`lpE@jeSE7!qcJ^S zCQQTig3hvCBX!HLQzgb~^jt32GEgA2oHceT*aSyey}$v8HFOhAmtnj96Z< zK(=t*V0NM_hPjnRCF zEb1n)b6q*S-D;f@tpbZz?ZOqkjFkdxkIArEJfTpXGEz}0#4HwDZm3?V8<^_KR@JIt zrd+xH^JN9Lv7#$oxq*QiR%*mD>Pjn51ye)MW~77YtzJmMlS;&><%Q-UH`+dEp^zf4 zK7um2i`LLe=V)u<>DD*J$6MvpQZ+v3g`=%*5p0d46cq4E8-g7ZRp05B^_sADum;L} z9BsBifmE?Qt0aKGP=|q**!EWF=PNlF)JCbR?LwB+J~gV!IVlG)1se^b=(f|gQT(y@ z*n#)4x2#qBPx)#-HLeb*33VXntBIShq53A;m_%zr1gV3wzV)eFJ?c;!W;JO|T8VYX z#e27&oCV%04E|4c!vND{!`8?wCxMPEJN9q%%~m0TF&KFR1w_!Sbda@zHWTfb0;n*- zqp0~i?2m5_x5rog?R8+|_34%bb)x2f->(ZgIxkKjUC11=kKWkUm2%Z2Wv1WJ8Kz2^ zC>-L%>Z)fCc|y*f2G;0OD@-rovU0m>kP?`oRkJwhNo=P^?0((K;ylx^P#o3j@X;LeBk zOdGDh#M}C9paUJyUN>323~)A!^=%#4W=X$8IIbr>VZ)99{W3^!W|b!%!?l{x$}6`G zTjB-gz(tMOqd8!(+@0B6lZ@#5Ko7u@Me?(gK%Ww>vyvAq5nLb^^@2>VkZPUT{a5+% zKnxd%RRZQQCheKFr~tc`>bH5aaBhvDx>$&(a?JK+vi8trT*OAl{9_BNt1F5vX3i=0 z*ebEMaRDlUP;B55B-p44s-IJqGZC+cn6z#Y4Fd6EU9<{UgBP$k@z<;X)SoxlJ>23} zFVUrL5xEz#$j5a;8Qjf9!n|I}FsoB74~Gv`L_T3&_|hkQ>v)NX;3X6ABub1ePXx!- zC^)pnxK|Mv*b@llAskWo&s!>-+q^b6KexQNq+PzYyxAy%&8{#y?}W|fV__2vfq#CR zYVDgdTifxbnx(8Uh*s+|0+UVBsB`Zp>W^>Y76Sh7vJr@xR)B)k?pffz<#*%Jg%$dl#i3ulFbrYla7v048eXw1C;>!~crXCDBM?P>;b5A6vs6UTy-@E9_@h?96{IdsB&*k*X#Hcf#a}y)Z*hvV( zO{Sd5({6IoNvqEFHAh`{)_>tledHz&JqWu=0RXif5d@InQsA;_X^}L1|!0C1!N(~J4Yg!)O8x&q8EKdwW)8Gl)*P(=E{QCaDI>Alxd%4kY zB+%Q?{O~I%ELWbsyYO;&;;T=;{M3~v-H}Uf?Bsq>N(LM$vDbsf4}Ww1>+`Ohc1ITX z{o**NlY1dFG4;*z*UPS)aYwGWv4q=4_rv}P7##Um^q7BM*h9R11zws?Ght7v7OGW? zzk(Z?Tl~S{$y}KwZ798EJ=FDU(IB=1Y-g?ZtAU^hAQG&%E6#Cr`h{{{FgZ1(^F literal 0 HcmV?d00001 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..8583acd --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + +class FreshserviceTicketConversation(models.Model): + _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 (Customer)'), + ], 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): + 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..e83b778 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +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.""" + res = {d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date} + if not env or not hasattr(env, '__getitem__'): + return res + 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): + _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', 'Internal (Waiting on Us)'), + ('customer', 'Customer (Waiting on User)'), + ('on_hold', 'On Hold (SLA is Paused)'), + ('third_party', 'Waiting on Third Party (SLA is Paused)'), + ('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'), + ('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'), + ('external_activity', 'External Activity (SLA is Paused)'), + ('resolved_idle', 'Resolved (Ticket Closed/Idle)'), + ], string='Tanggung Jawab', required=True, default='internal', index=True) + + 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): + 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): + 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): + 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..cb5e5d6 --- /dev/null +++ b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py @@ -0,0 +1,965 @@ +# -*- coding: utf-8 -*- +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): + _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='Agen 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') + resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)') + + is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, 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='Agen (Waiting on Us)', + compute='_compute_time_metrics', + store=True + ) + + total_customer_time_days = fields.Integer( + string='Total Waktu di User (Hari)', + compute='_compute_time_metrics', + store=True + ) + total_customer_time_hours = fields.Float( + string='Total Waktu di User (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + total_customer_time_display = fields.Char( + string='User (Waiting on User)', + 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 Waktu Selesai', + compute='_compute_time_metrics', + store=True + ) + + current_responsibility = fields.Selection([ + ('internal', 'Waiting on Us (Internal)'), + ('customer', 'Waiting on User (Customer)'), + ('closed', 'Tiket Selesai'), + ], string='Status Tanggung Jawab Saat Ini', 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.is_active' + ) + def _compute_time_metrics(self): + 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. + agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'internal') + cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer') + agent_days = round(agent_hours / 8.0, 2) + cust_days = round(cust_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.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 "-" + + # 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): + """Menugaskan tiket ini ke user Odoo yang sedang login""" + 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 action_sync_from_freshservice(self): + """ + Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi). + Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan. + Mendukung concurrency row-lock untuk keamanan multi-user. + """ + 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('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = get_param('freshservice_ticket_audit.freshservice_api_key', '') + use_mock = get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) + + 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 = [] + current_state = 'internal' + 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) dengan Sticky State Machine. + # Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state. + combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower() + is_timesheet = any(k in combined_text for k in ('timesheet', 'time spent', 'create timesheet')) + + if is_timesheet: + stage_name = 'Create Timesheet' + responsibility = 'external_activity' + else: + status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower() + explicit_status = 'set status as' in status_text + is_requester_reopen_workflow = ( + actor_type == 'System' + and 'reopen tickets when the requester responds' in status_text + ) + requester_email = (self.requester_email or '').strip().lower() + is_agent_reply_to_requester = ( + actor_type.strip().lower() == 'agent' + and bool(re.search(r'replied\s+to', status_text, re.I)) + ) + is_agent_assignment = bool(re.search(r'\bset\s+agent\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)) + confirmation_to_user = bool(re.search(r'(mohon|please|harap|kindly).{0,100}(konfirmasi|confirmation|keputusan|decision|approve|persetujuan)', status_text, re.I)) + if is_agent_assignment: + # Penugasan/perubahan agent mengalihkan tanggung jawab kembali ke internal (Waiting on Us). + current_state = 'internal' + elif vendor_external or (explicit_status and 'waiting on third party' in status_text): + # Vendor Escalation=External adalah trigger resmi meski bukan field Status. + current_state = 'third_party' + elif explicit_status and 'waiting on internal discussion' in status_text: + current_state = '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')): + current_state = 'customer' + elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')): + current_state = 'on_hold' + elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')): + current_state = 'resolved_idle' + 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')): + current_state = 'internal' + elif is_agent_reply_to_requester: + # Agent sudah membalas requester; giliran berikutnya + # menunggu respons user dan dibebankan ke end user (customer). + current_state = 'customer' + # Balasan requester tidak serta-merta mengalihkan pembebanan ke internal + # sebelum tiket resmi di-reopen oleh workflow atau agent. + elif actor_type == 'Agent' and current_state in ('on_hold', 'internal_discussion') and confirmation_to_user: + # Pengecualian: note Agent yang eksplisit meminta keputusan/konfirmasi user. + current_state = 'customer' + + responsibility = current_state + + # 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent. + is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or '')) + 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 + + 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: + responsibility = 'scheduled_meeting' + meeting_wait_until = False + elif meeting_waiting_for_followup and start_dt_obj: + # No parsable Time: waits together until Agent follow-up. With Time:, + # the period from meeting start until that same follow-up is internal. + if meeting_start_dt and start_dt_obj >= meeting_start_dt: + responsibility = 'internal' + if is_agent_followup: + meeting_waiting_for_followup = False + meeting_start_dt = False + meeting_wait_until = start_dt_obj + else: + responsibility = 'scheduled_meeting' + if not meeting_start_dt and is_agent_followup: + meeting_waiting_for_followup = False + meeting_wait_until = start_dt_obj + + # 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, + 'actor_name': actor_name, + 'actor_type': actor_type, + 'activity_content': full_activity_content or stage_name, + 'start_date': start_dt, + 'end_date': end_dt, + } + # Split every affected interval at the scheduled start precisely: + # invitation → meeting = shared SLA-paused wait; meeting → next activity = internal follow-up. + if interval_meeting_start and end_dt_obj and start_dt_obj < interval_meeting_start < end_dt_obj: + waiting_values = dict( + log_values, + responsibility='scheduled_meeting', + end_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'), + ) + followup_values = dict( + log_values, + stage_name='Scheduled Meeting / Follow-up', + responsibility='internal', + activity_content='%s\n[Auto split: meeting start → next Agent follow-up]' % (full_activity_content or stage_name), + start_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'), + ) + log_vals_list.extend([(0, 0, waiting_values), (0, 0, followup_values)]) + else: + log_vals_list.append((0, 0, log_values)) + + if log_vals_list: + # Baru ganti snapshot setelah daftar audit trail pengganti siap. + self.stage_log_ids.unlink() + self.write({'stage_log_ids': log_vals_list}) + + # 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 = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "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, + } + } + + def action_toggle_mock_mode(self): + """Toggle instan mode antara Mock dan Live API""" + param_obj = self.env['ir.config_parameter'].sudo() + current = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) + new_val = not current + param_obj.set_param('freshservice_ticket_audit.freshservice_use_mock', str(new_val)) + + status_label = "MOCK DATA (Simulasi)" if new_val else "LIVE API FRESHSERVICE" + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Mode Berhasil Diubah', + 'message': f'Sistem sekarang beralih ke: {status_label}. Klik tombol "Tarik Data Freshservice" untuk menyegarkan data.', + 'type': 'info', + '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('freshservice_ticket_audit.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('freshservice_ticket_audit.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('freshservice_ticket_audit.last_list_sync_time', str(now_ts)) + + base_url = param_obj.get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = param_obj.get_param('freshservice_ticket_audit.freshservice_api_key', '') + use_mock = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) + + try: + client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) + 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): + """Ambil angka ticket ID dari domain pencarian agar bisa fetch on-demand.""" + 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): + """Ambil tiket tertentu dari Freshservice jika belum ada di Odoo.""" + 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): + """ + Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket', + lalu langsung membuka tampilan list tiket secara real-time! + Batas jumlah tiket disesuaikan dengan konfigurasi di Settings. + """ + 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('freshservice_ticket_audit.action_freshservice_ticket') + param_obj = self.env['ir.config_parameter'].sudo() + try: + sync_limit = int(param_obj.get_param('freshservice_ticket_audit.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='', args=None, operator='ilike', limit=100): + 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, args=args, operator=operator, limit=limit) + + @api.model + def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): + """ + Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket. + Batas jumlah tiket disesuaikan dengan konfigurasi di Settings. + """ + 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]: + """ + Ketika pengguna membuka form view (detail tiket), jika detail audit trail + belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand. + """ + 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 Freshservice' di header tabel jika user ingin refresh paksa. + """ + 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..3b36c9f --- /dev/null +++ b/gmn_freshservice/models/gmn_res_config_settings_v01.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api +from odoo.exceptions import UserError +import requests + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + freshservice_url = fields.Char( + string='Freshservice URL', + config_parameter='freshservice_ticket_audit.freshservice_url', + default='https://ptglobalmultimedianetwork.freshservice.com' + ) + freshservice_api_key = fields.Char( + string='Freshservice API Key', + config_parameter='freshservice_ticket_audit.freshservice_api_key' + ) + freshservice_sync_limit = fields.Integer( + string='Limit Sinkronisasi Tiket', + config_parameter='freshservice_ticket_audit.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='freshservice_ticket_audit.freshservice_use_mock', + # default=True, + help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.' + ) + + def action_test_freshservice_connection(self): + """Uji konektivitas ke server Freshservice""" + 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_ir_model_access_v01.csv b/gmn_freshservice/security/gmn_ir_model_access_v01.csv new file mode 100644 index 0000000..3657941 --- /dev/null +++ b/gmn_freshservice/security/gmn_ir_model_access_v01.csv @@ -0,0 +1,5 @@ +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,base.group_user,1,1,1,1 +access_freshservice_ticket_stage_log_user,freshservice.ticket.stage.log.user,model_freshservice_ticket_stage_log,base.group_user,1,1,1,1 +access_freshservice_ticket_conversation_user,freshservice.ticket.conversation.user,model_freshservice_ticket_conversation,base.group_user,1,1,1,1 +access_freshservice_ticket_bottleneck_wizard_user,freshservice.ticket.bottleneck.wizard.user,model_freshservice_ticket_bottleneck_wizard,base.group_user,1,1,1,1 diff --git a/gmn_freshservice/services/__init__.py b/gmn_freshservice/services/__init__.py new file mode 100644 index 0000000..64f6e74 --- /dev/null +++ b/gmn_freshservice/services/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +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..28d18a6495308a99794416e5f9ab13087746cab8 GIT binary patch literal 217 zcmX@j%ge<81UfU$WLW{}#~=<2FhLog1%Qm{3@HpLj5!Rsj8Tk?43$ip%r6;%!kUb? zgwu*ri!+K-i^?*SQ{$6!GE?(P{4|+vF%&Ta6|7|V3^L}IXNpx!eoB76f}tgtj!DnW zi_gtZDa}cZK{22tGdVl8BtEe;C9@<3YHD#ze0*kJW=VX!UP0w84jYI*yCM#t$sku0 SgBTx}85tR$F(?+X0XYD=hdT=Z literal 0 HcmV?d00001 diff --git a/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bad2a3e13629ab8fd0cc00082fa00b81efb4f12d GIT binary patch literal 213 zcmdPqSz89svyy5*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 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 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_ticket_stage_log_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml new file mode 100644 index 0000000..fec3f3b --- /dev/null +++ b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml @@ -0,0 +1,106 @@ + + + + + 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..931c8d1 --- /dev/null +++ b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml @@ -0,0 +1,334 @@ + + + + + freshservice.ticket.form + freshservice.ticket + +
+
+
+ + + + +
+
+ + +
+
+
+ First Response +

+ +

+ + Oleh: + +
+
+
+
+ Agent +

+ +

+ Waiting on Us +
+
+
+
+ User +

+ +

+ Waiting on Customer +
+
+
+
+ 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..cfc8055 --- /dev/null +++ b/gmn_freshservice/views/gmn_menu_views_v01.xml @@ -0,0 +1,49 @@ + + + + + + + + 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..d693eaa --- /dev/null +++ b/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml @@ -0,0 +1,47 @@ + + + + res.config.settings.view.form.freshservice + res.config.settings + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + Pengaturan Freshservice + res.config.settings + form + main + {'module': 'freshservice_ticket_audit'} + +
diff --git a/gmn_freshservice/wizard/__init__.py b/gmn_freshservice/wizard/__init__.py new file mode 100644 index 0000000..9734d0b --- /dev/null +++ b/gmn_freshservice/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import gmn_freshservice_ticket_bottleneck_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..67d7498059d4bc67c36cfd85380b69161e44c9f1 GIT binary patch literal 233 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 eXKeAS(O*XB]+>', ' ', text) + clean = re.sub(r'\s+', ' ', clean).strip() + return clean + + 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))) + + 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' + }) + 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 + ) + 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 + ) + + 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 self._strip_html(conv.body) 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=None + ): + """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'LAPORAN ANALISIS TIKET #{t.external_ticket_id}', title_fmt) + ws1.write( + 'B3', + f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}', + subtitle_fmt + ) + + # Info Header Tiket + ws1.merge_range('B5:H5', 'Informasi Tiket', section_fmt) + + ws1.write('B6', 'ID Freshservice', td_fmt) + ws1.write('C6', f'#{t.external_ticket_id}', td_fmt) + ws1.write('D6', 'Status Saat Ini', 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', 'Agen 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', 'Waktu Dibuat', td_fmt) + ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt) + ws1.write('D10', 'Waktu Selesai', 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' + }) + ws1.write('B13', 'First Response', kpi_wh_label) + ws1.write('C13', 'Total Waktu', kpi_wh_label) + ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_wh_label) + ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label) + ws1.write('F13', 'Total SLA Paused', kpi_wh_label) + ws1.write('G13', 'Resolved / Idle', kpi_wh_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 = fields.Datetime.now() + wh_it_direct = 0.0 + wh_internal_hold = 0.0 + wh_cust = 0.0 + wh_meeting = 0.0 + wh_vendor = 0.0 + wh_other_paused = 0.0 + wh_resolved_idle = 0.0 + stage_wh_map = {} + for s in t.stage_log_ids: + # Segmen aktif dihitung hingga satu timestamp cetak yang konsisten. + s_wh = compute_working_hours(self.env, s.start_date, s.end_date or report_as_of) + stage_wh_map[s.id] = s_wh + if s.responsibility == 'internal': + wh_it_direct += s_wh + elif s.responsibility in ('on_hold', 'internal_discussion'): + # Sesuai Aturan 9: On Hold & Internal Discussion murni internal adalah Beban IT + wh_internal_hold += s_wh + elif s.responsibility == 'customer': + wh_cust += s_wh + elif s.responsibility == 'scheduled_meeting': + wh_meeting += s_wh + elif s.responsibility == 'third_party': + wh_vendor += s_wh + elif s.responsibility == 'external_activity': + wh_other_paused += s_wh + elif s.responsibility == 'resolved_idle': + wh_resolved_idle += s_wh + + wh_agent = wh_it_direct + wh_internal_hold + wh_paused = wh_meeting + wh_vendor + wh_other_paused + 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_box_val) + ws1.write('E14', format_working_duration(wh_cust), kpi_box_val) + ws1.write('F14', format_working_duration(wh_paused), kpi_box_val) + ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_val) + ws1.write('H14', 'Senin-Jumat, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 Jam/Hari)\nDB UTC → +7 WIB', kpi_box_val) + + # REKAPITULASI BEBAN TANGGUNG JAWAB — harus identik dengan total Sheet 2. + ws1.merge_range('B16:I16', 'REKAPITULASI BEBAN TANGGUNG JAWAB & MATRIKS BOTTLENECK', section_fmt) + ws1.write('B17', 'Kategori Beban', th_fmt) + ws1.merge_range('C17:F17', 'Keterangan Operasional', th_fmt) + ws1.write('G17', 'Jam Kerja', th_fmt) + ws1.write('H17', 'Hari Kerja Efektif', th_fmt) + ws1.write('I17', '% Beban', th_fmt) + recap_rows = [ + ('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / In Progress).', wh_it_direct), + ('Waiting on Us (IT - Internal Hold & Discussion)', 'Tiket ditahan/dianalisa internal IT (Aturan 9: default Beban IT).', wh_internal_hold), + ('Waiting on User (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust), + ('Tunggu Bersama (Scheduled Meeting)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting), + ('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor), + ('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused), + ('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle), + ('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total), + ] + for recap_row, (label, description, hours) in enumerate(recap_rows, start=18): + label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (section_fmt if label == 'Grand Total Umur Tiket' 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) + ws1.write(recap_row, 6, hours, td_dec_fmt) + ws1.write(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt) + ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_fmt) + + # TABEL RANKING BOTTLENECK PER STAGE + # Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga + # ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25. + ws1.merge_range('B28:I28', 'RANKING BOTTLENECK PER STAGE (Diurutkan dari Tahapan Paling Lama)', section_fmt) + + headers_bn = ['Rank', 'Tahapan / Status', 'Tanggung Jawab', 'Pengguna', 'Hari Kerja Efektif', 'Jam Kerja', '% Kontribusi', 'Status'] + for col_idx, h in enumerate(headers_bn, start=1): + ws1.write(29, 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 = 30 + primary_bn_text = "Tidak terdeteksi delay signifikan." + primary_bn_actor = "-" + primary_bn_stage = "-" + primary_bn_days = 0 + + 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) + pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0 + if stage.responsibility == 'internal': + resp_label = 'Waiting on Us (Internal)' + resp_fmt = bn_badge_fmt + elif stage.responsibility == 'customer': + resp_label = 'Waiting on User (Requester)' + resp_fmt = normal_badge_fmt + elif stage.responsibility == 'on_hold': + resp_label = 'On Hold (Internal Hold / Waiting on Us)' + resp_fmt = bn_badge_fmt + elif stage.responsibility == 'third_party': + resp_label = 'Waiting on Third Party (SLA is Paused)' + resp_fmt = sla_paused_fmt or td_center_fmt + elif stage.responsibility == 'internal_discussion': + resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)' + resp_fmt = bn_badge_fmt + elif stage.responsibility == 'scheduled_meeting': + resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)' + resp_fmt = sla_paused_fmt or td_center_fmt + elif stage.responsibility == 'external_activity': + resp_label = 'External Activity (SLA is Paused)' + resp_fmt = sla_paused_fmt or td_center_fmt + else: + resp_label = 'Resolved (Ticket Closed/Idle)' + resp_fmt = sla_paused_fmt or td_center_fmt + is_primary = (rank_idx == 1 and stage_wh > 0.0) + + ws1.write(row, 1, f"#{rank_idx}", bn_badge_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(row, 5, stage_wd_eff, td_dec_fmt) + ws1.write(row, 6, stage_wh, td_dec_fmt) + ws1.write(row, 7, pct, td_pct_fmt) + ws1.write(row, 8, 'Sedang Berjalan' if stage.is_active else 'Selesai', td_center_fmt) + row += 1 + + # KESIMPULAN HIGHLIGHT BOX + row += 1 + # ws1.merge_range(row, 1, row, 8, 'KESIMPULAN BOTTLENECK UNTUK BAHAN MEETING', section_fmt) + row += 1 + # highlight_fmt = workbook.add_format({ + # 'bold': True, 'font_size': 10, 'font_name': 'Calibri', + # 'bg_color': '#FDEDEC', 'font_color': '#78281F', 'border': 1, 'border_color': '#E6B0AA' + # }) + # ws1.merge_range(row, 1, row + 1, 8, primary_bn_text, highlight_fmt) + + # SHEET 2: KRONOLOGI AUDIT TRAIL + ws2 = workbook.add_worksheet('Audit Trail & Kronologi') + ws2.set_column('A:A', 5) + ws2.set_column('B:B', 6) + 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', 16) + ws2.set_column('H:H', 22) + ws2.set_column('I:I', 18) + ws2.set_column('J:J', 18) + ws2.set_column('K:K', 35) + ws2.set_column('L:L', 55) + + ws2.merge_range('B2:L2', f'KRONOLOGI LENGKAP AUDIT TRAIL TIKET #{t.external_ticket_id}', title_fmt) + headers_ws2 = [ + 'No', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status', + 'Tanggung Jawab', 'Tipe Pengguna', 'Nama Pengguna', + 'Hari Kerja Efektif', 'Jam Kerja', + 'Keterangan Aktivitas', 'Isi Balasan / Pesan' + ] + for col_idx, h in enumerate(headers_ws2, start=1): + ws2.write(3, col_idx, h, th_fmt) + + row2 = 4 + for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1): + if log.responsibility == 'internal': + resp_label = 'Waiting on Us (Internal)' + elif log.responsibility == 'customer': + resp_label = 'Waiting on User (Customer)' + elif log.responsibility == 'on_hold': + resp_label = 'On Hold (Internal Hold / Waiting on Us)' + elif log.responsibility == 'third_party': + resp_label = 'Waiting on Third Party (SLA is Paused)' + elif log.responsibility == 'internal_discussion': + resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)' + elif log.responsibility == 'scheduled_meeting': + resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)' + elif log.responsibility == 'external_activity': + resp_label = 'External Activity (SLA is Paused)' + else: + resp_label = 'Resolved (Ticket Closed/Idle)' + 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_days = compute_calendar_days(self.env, log.start_date, log.end_date or report_as_of) + wh = compute_working_hours(self.env, log.start_date, log.end_date or report_as_of) + wd_eff = round(wh / 8.0, 2) + + # Tampilkan waktu dalam WIB (UTC+7) untuk kemudahan baca + from datetime import timedelta as _td + def _to_wib_str(dt): + if not dt: + return 'Aktif' + try: + return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M') + except Exception: + return str(dt) + + ws2.write(row2, 1, idx2, td_center_fmt) + ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt) + ws2.write(row2, 3, _to_wib_str(log.end_date), td_center_fmt) + ws2.write(row2, 4, log.stage_name or '-', td_fmt) + ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt))) + ws2.write(row2, 6, log.actor_type or '-', td_center_fmt) + ws2.write(row2, 7, log.actor_name or '-', td_fmt) + ws2.write(row2, 8, wd_eff, td_dec_fmt) + ws2.write(row2, 9, wh, td_dec_fmt) + ws2.write(row2, 10, self._strip_html(raw_act_main) or '-', td_fmt) + ws2.write(row2, 11, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt) + row2 += 1 + + # Grand total menggunakan sumber data yang sama dengan Sheet 1 (stage_wh_map). + ws2.merge_range(row2, 1, row2, 7, 'GRAND TOTAL UMUR TIKET', section_fmt) + ws2.write(row2, 8, round(wh_total / 8.0, 2), td_dec_fmt) + ws2.write(row2, 9, wh_total, td_dec_fmt) + ws2.merge_range(row2, 10, row2, 11, 'Sama dengan Sheet 1: seluruh stage log termasuk SLA Paused dan Resolved / Idle.', td_fmt) + + # SHEET 3: RIWAYAT PERCAKAPAN + ws3 = workbook.add_worksheet('Percakapan & Respon') + ws3.set_column('A:A', 5) + ws3.set_column('B:B', 8) + ws3.set_column('C:C', 18) + ws3.set_column('D:D', 22) + ws3.set_column('E:E', 24) + ws3.set_column('F:F', 55) + + ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt) + headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan'] + for col_idx, h in enumerate(headers_ws3, start=1): + ws3.write(3, col_idx, h, th_fmt) + + row3 = 4 + for idx3, conv in enumerate(t.conversation_ids.sorted('created_at'), start=1): + sender_type_label = 'User (Requester)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent' + ws3.write(row3, 1, idx3, td_center_fmt) + ws3.write(row3, 2, str(conv.created_at or '-'), 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 + + # Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard + html_out = f""" +
+

📊 Hasil Analisis Bottleneck Tiket #{t.external_ticket_id}

+

Subjek: {t.name or '-'}

+

Pemohon: {t.requester_name or '-'} | Departemen: {t.department_name or '-'} | Agen IT: {t.responder_name or '-'}

+
+
+
+
+ TOTAL WAKTU + {t.resolution_time_days} Hari +
+
+
+
+ WAITING ON US (IT) + {t.total_agent_time_days} Hari +
+
+
+
+ WAITING ON USER + {t.total_customer_time_days} Hari +
+
+
+
+ FIRST RESPONSE + {t.first_response_time_days} Hari +
+
+
+ +
+ """ + #
+ # 🚨 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 + ): + """Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim).""" + ws = workbook.add_worksheet('Ranking Bottleneck Tiket') + 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', 'LAPORAN RANKING BOTTLENECK & EVALUASI SLA TIKET (BAHAN MEETING)', title_fmt) + ws.write( + 'B3', + f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}', + subtitle_fmt + ) + + headers = [ + 'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT', + 'Status', + 'Total Hari Kalender', 'Total Jam Kerja (08:30-17:30)', 'Total Hari Kerja Efektif', + 'Waktu IT (Hari Kalender)', 'Waktu IT (Jam Kerja)', + 'Waktu User (Hari Kalender)', 'Waktu User (Jam Kerja)', + 'Bottleneck Utama (Stage)', 'Durasi Bottleneck (Hari Kalender)', 'Durasi Bottleneck (Jam Kerja)' + ] + 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 = 'Internal (IT)' if top_stage[0].responsibility == 'internal' else 'Customer (User)' + 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) + if s.responsibility == 'internal': + t_wh_agent += s_wh + elif s.responsibility == 'customer': + t_wh_cust += s_wh + # on_hold: SLA paused, tidak dihitung di IT maupun User + + 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} Hari / {t_wh_total:.1f} Jam Kerja)") + + 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('Detail Stage Log Semua Tiket') + 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', 'RINCIAN SELURUH TRANSISI STAGE & DURASI TIKET', title_fmt) + headers2 = [ + 'ID Tiket', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status', + 'Tanggung Jawab', 'Pengguna', + 'Hari Kalender', 'Jam Kalender', + 'Jam Kerja (08:30-17:30)', 'Hari Kerja Efektif', + 'Keterangan Aktivitas', 'Isi Balasan / Pesan' + ] + 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 = 'Internal (Waiting on Us)' + elif log.responsibility == 'customer': + resp_label = 'Customer (Waiting on User)' + elif log.responsibility == 'on_hold': + resp_label = 'On Hold (SLA is Paused)' + elif log.responsibility == 'third_party': + resp_label = 'Waiting on Third Party (SLA is Paused)' + else: + resp_label = 'Waiting on Internal Discussion (SLA is Paused)' + 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 'Aktif' + 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) + ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_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""" +
+

📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat

+

Total Tiket yang Dievaluasi: {len(sorted_tickets)} Tiket

+

Rata-rata Waktu Penyelesaian: {avg_days:.1f} Hari Kalender

+
+

⚠️ Top 5 Tiket dengan Durasi Terlama:

+
    + {''.join(f'
  • {item}
  • ' for item in slowest_tickets)} +
+ File Excel siap digunakan sebagai bahan evaluasi dan meeting manajemen. +
+ """ + 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..c1cbdb5 --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml @@ -0,0 +1,85 @@ + + + + + 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 + +