commit ebdc2ee30411b2cfb2ea32802dfd821bd6a0211a Author: Madanil Date: Mon Oct 19 08:07:51 2026 +0700 [ADD] iitial commit gmn fresh service 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 0000000..2202b12 Binary files /dev/null and b/gmn_freshservice/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..2bab524 Binary files /dev/null and b/gmn_freshservice/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc b/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc new file mode 100644 index 0000000..2839325 Binary files /dev/null and b/gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc differ diff --git a/gmn_freshservice/data/gmn_demo_data_v01.xml b/gmn_freshservice/data/gmn_demo_data_v01.xml new file mode 100644 index 0000000..f71eff5 --- /dev/null +++ b/gmn_freshservice/data/gmn_demo_data_v01.xml @@ -0,0 +1,123 @@ + + + + + + Kendala Koneksi Jaringan & SAP Client - Dept Finance + 129 + Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek. + resolved + high + Ahmad Dani (User) + ahmad.dani@lyman.co.id + Budi Santoso (IT Support) + Finance & Accounting + 2026-10-10 08:30:00 + 2026-10-12 17:00:00 + 2026-10-10 08:52:00 + Budi Santoso (IT Support) + 2026-10-11 16:30:00 + 1 + + + + + + Open + internal + Ahmad Dani (User) + Requester + Tiket dibuat via Web Portal dengan status Open + 2026-10-10 08:30:00 + 2026-10-10 08:52:00 + + + + + + In Progress + internal + Budi Santoso (IT Support) + Agent + First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan. + 2026-10-10 08:52:00 + 2026-10-10 09:30:00 + + + + + + Pending User + customer + Budi Santoso (IT Support) + Agent + Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap. + 2026-10-10 09:30:00 + 2026-10-11 14:00:00 + + + + + + In Progress + internal + Ahmad Dani (User) + Requester + User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning. + 2026-10-11 14:00:00 + 2026-10-11 16:30:00 + + + + + + Resolved + internal + Budi Santoso (IT Support) + Agent + Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal. + 2026-10-11 16:30:00 + 2026-10-11 16:30:00 + + + + + + 501 + Budi Santoso (IT Support) + 0 + Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3. + 2026-10-10 08:52:00 + + + + + + 502 + Budi Santoso (IT Support) + 0 + Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye? + 2026-10-10 09:30:00 + + + + + + 503 + Ahmad Dani (User) + 1 + Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus. + 2026-10-11 14:00:00 + + + + + + 504 + Budi Santoso (IT Support) + 0 + Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali. + 2026-10-11 16:30:00 + + + diff --git a/gmn_freshservice/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 0000000..d859222 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..3f2128f Binary files /dev/null and b/gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc new file mode 100644 index 0000000..ef394a4 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc new file mode 100644 index 0000000..86c604f Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc new file mode 100644 index 0000000..5d4dc48 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc new file mode 100644 index 0000000..816fe56 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_conversation.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc new file mode 100644 index 0000000..1bbc495 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc new file mode 100644 index 0000000..692040b Binary files /dev/null and b/gmn_freshservice/models/__pycache__/freshservice_ticket_stage_log.cpython-314.pyc differ diff --git a/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc new file mode 100644 index 0000000..253d940 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc new file mode 100644 index 0000000..dfc64c6 Binary files /dev/null and b/gmn_freshservice/models/__pycache__/res_config_settings.cpython-314.pyc differ diff --git a/gmn_freshservice/models/gmn_freshservice_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 0000000..28d18a6 Binary files /dev/null and b/gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc b/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..bad2a3e Binary files /dev/null and b/gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc new file mode 100644 index 0000000..0c8ce89 Binary files /dev/null and b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-312.pyc differ diff --git a/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc new file mode 100644 index 0000000..61a6cd2 Binary files /dev/null and b/gmn_freshservice/services/__pycache__/freshservice_client.cpython-314.pyc differ diff --git a/gmn_freshservice/services/gmn_freshservice_client_v01.py b/gmn_freshservice/services/gmn_freshservice_client_v01.py new file mode 100644 index 0000000..01b6024 --- /dev/null +++ b/gmn_freshservice/services/gmn_freshservice_client_v01.py @@ -0,0 +1,637 @@ +# -*- coding: utf-8 -*- +import datetime +import logging +import requests +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +class FreshserviceClient: + """ + Client adapter untuk integrasi Freshservice API v2. + Mendukung dua mode: + 1. Mock Mode (Format data 100% identik dengan dokumentasi resmi Freshservice API) + 2. Live API Mode (Direct HTTP call ke endpoint perusahaan) + """ + + STATUS_MAP = { + 2: 'open', + 3: 'pending', + 4: 'resolved', + 5: 'closed', + } + + PRIORITY_MAP = { + 1: 'low', + 2: 'medium', + 3: 'high', + 4: 'urgent', + } + + _agent_cache = {} + _dept_cache = {} + + def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=True): + self.base_url = (base_url or "").rstrip("/") + self.api_key = api_key or "" + self.use_mock = use_mock + + def get_agent_name(self, agent_id): + """Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)""" + if not agent_id or self.use_mock: + return "" + if agent_id in self._agent_cache: + return self._agent_cache[agent_id] + + try: + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + url = f"{self.base_url}/api/v2/agents/{agent_id}" + res = requests.get(url, auth=auth, headers=headers, timeout=10) + if res.status_code == 200: + agent = res.json().get("agent", {}) + first_name = agent.get("first_name") or "" + last_name = agent.get("last_name") or "" + full_name = f"{first_name} {last_name}".strip() or agent.get("email") or str(agent_id) + self._agent_cache[agent_id] = full_name + return full_name + except Exception as e: + _logger.warning("Gagal fetch agent %s dari Freshservice: %s", agent_id, e) + + self._agent_cache[agent_id] = "" + return "" + + def get_department_name(self, dept_id): + """Ambil nama departemen Freshservice berdasarkan ID (dengan in-memory cache)""" + if not dept_id or self.use_mock: + return "" + if dept_id in self._dept_cache: + return self._dept_cache[dept_id] + + try: + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + url = f"{self.base_url}/api/v2/departments/{dept_id}" + res = requests.get(url, auth=auth, headers=headers, timeout=10) + if res.status_code == 200: + dept = res.json().get("department", {}) + name = dept.get("name") or "" + self._dept_cache[dept_id] = name + return name + except Exception as e: + _logger.warning("Gagal fetch department %s dari Freshservice: %s", dept_id, e) + + self._dept_cache[dept_id] = "" + return "" + + def fetch_ticket_full_data(self, ticket_id): + """ + Mengambil paket lengkap data tiket: + - Ticket Details + SLA Stats + - Audit Trail Activities + - Conversations (Replies & Notes) + """ + if self.use_mock: + _logger.info("Mengambil data tiket Freshservice via MOCK ENGINE untuk ID: %s", ticket_id) + return self._get_mock_payload(ticket_id) + else: + _logger.info("Menghubungi LIVE Freshservice API: %s untuk ID: %s", self.base_url, ticket_id) + return self._get_live_payload(ticket_id) + + def fetch_recent_tickets(self, limit=200, tiket_id=None): + """ + Mengambil daftar tiket terbaru dari Freshservice API (default 200 tiket terbaru). + Freshservice API v2 membatasi per_page maksimum 100 tiket per request, + sehingga dilakukan paginasi (page 1, page 2, dst) secara cepat hingga mencapai limit. + Hanya mengambil ringkasan tiket dan status (tanpa membebani API dengan detail activities). + """ + if self.use_mock: + return self._get_mock_recent_tickets(limit=limit) + + if not self.base_url or not self.api_key: + raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!") + + auth = (self.api_key, "X") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + + tickets = [] + page = 1 + per_page = 100 + while len(tickets) < limit: + fetch_count = min(per_page, limit - len(tickets)) + url = f"{self.base_url}/api/v2/tickets?order_by=created_at&order_type=desc&per_page={fetch_count}&page={page}&include=requester,stats" + try: + res = requests.get(url, auth=auth, headers=headers, timeout=25) + if res.status_code == 200: + batch = res.json().get("tickets", []) + if not batch: + break + + # Resolusi info pemohon, agen responder, dan departemen + for t in batch: + req = t.get('requester') or {} + t['requester_name'] = req.get('name') or t.get('requester_name') or '' + t['requester_email'] = req.get('email') or t.get('requester_email') or t.get('email') or '' + + resp_id = t.get('responder_id') + if resp_id: + t['responder_name'] = self.get_agent_name(resp_id) + + dept_id = t.get('department_id') + if dept_id: + t['department_name'] = self.get_department_name(dept_id) + elif t.get('custom_fields', {}).get('company_information_for_owner_of_ticket'): + t['department_name'] = t['custom_fields']['company_information_for_owner_of_ticket'] + + tickets.extend(batch) + if len(batch) < fetch_count: + # Data di Freshservice sudah habis + break + page += 1 + else: + _logger.warning("Gagal fetch recent tickets halaman %s (HTTP %s): %s", page, res.status_code, res.text) + break + except Exception as e: + _logger.error("Error fetching recent tickets from Freshservice: %s", e) + break + + return tickets[:limit] + + def _get_mock_recent_tickets(self, limit=200): + """Mock data ringkasan tiket untuk pengujian cepat tanpa membebani network""" + samples = [ + { + "id": 129, + "subject": "Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #129]", + "status": 4, + "priority": 3, + "requester_name": "Ahmad Dani (User)", + "responder_name": "Budi Santoso (IT Support)", + "department_name": "Finance & Accounting", + "created_at": "2026-10-10T08:30:00Z", + "due_by": "2026-10-12T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-10T08:52:00Z", + "resolved_at": "2026-10-11T16:30:00Z", + } + }, + { + "id": 9991, + "subject": "User Akses untuk modul report Account Receivables", + "status": 2, + "priority": 1, + "requester_name": "Henny Gunawan", + "responder_name": "Jonathan Tan", + "department_name": "Accounting", + "created_at": "2026-10-12T02:45:00Z", + "due_by": "2026-10-14T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-12T03:15:00Z", + } + }, + { + "id": 9966, + "subject": "Email gom@parahyangangolf.com - Your Certificate has Expired", + "status": 4, + "priority": 1, + "requester_name": "Rohmat tulloh", + "responder_name": "Prabowo Bowo", + "department_name": "IT Infrastructure", + "created_at": "2026-10-09T06:27:00Z", + "due_by": "2026-10-11T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-09T07:10:00Z", + "resolved_at": "2026-10-09T09:00:00Z", + } + }, + { + "id": 9950, + "subject": "Permintaan Install Ulang Windows Laptop Sales", + "status": 3, + "priority": 2, + "requester_name": "Siti Nurhaliza", + "responder_name": "Budi Santoso (IT Support)", + "department_name": "Sales & Marketing", + "created_at": "2026-10-11T09:15:00Z", + "due_by": "2026-10-13T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-11T10:00:00Z", + } + }, + { + "id": 9942, + "subject": "Reset Password Akun VPN Kantor Pusat", + "status": 5, + "priority": 2, + "requester_name": "Rudi Hermawan", + "responder_name": "Prabowo Bowo", + "department_name": "Human Resources", + "created_at": "2026-10-08T11:00:00Z", + "due_by": "2026-10-09T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-08T11:15:00Z", + "resolved_at": "2026-10-08T11:45:00Z", + } + }, + ] + return samples[:limit] + + # def _get_live_payload(self, ticket_id): + # if not self.base_url or not self.api_key: + # raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!") + + # auth = (self.api_key, "X") + # headers = {"Content-Type": "application/json", "Accept": "application/json"} + + # # 1. Ticket details & stats with requester + # url_ticket = f"{self.base_url}/api/v2/tickets/{ticket_id}?include=requester,stats" + # res_ticket = requests.get(url_ticket, auth=auth, headers=headers, timeout=20) + # if res_ticket.status_code != 200: + # raise UserError(f"Gagal mengambil tiket dari Freshservice (HTTP {res_ticket.status_code}): {res_ticket.text}") + # ticket_json = res_ticket.json().get("ticket", {}) + + # # Resolusi data pemohon (requester), agen (responder), dan departemen + # req = ticket_json.get('requester') or {} + # ticket_json['requester_name'] = req.get('name') or ticket_json.get('requester_name') or '' + # ticket_json['requester_email'] = req.get('email') or ticket_json.get('requester_email') or ticket_json.get('email') or '' + + # resp_id = ticket_json.get('responder_id') + # if resp_id: + # ticket_json['responder_name'] = self.get_agent_name(resp_id) + + # dept_id = ticket_json.get('department_id') + # if dept_id: + # ticket_json['department_name'] = self.get_department_name(dept_id) + # elif ticket_json.get('custom_fields', {}).get('company_information_for_owner_of_ticket'): + # ticket_json['department_name'] = ticket_json['custom_fields']['company_information_for_owner_of_ticket'] + + # # 2. Activities (Audit Trail) with pagination support + # url_activities = f"{self.base_url}/api/v2/tickets/{ticket_id}/activities" + # activities_json = [] + # page_count = 0 + # while url_activities and page_count < 10: + # page_count += 1 + # try: + # res_act = requests.get(url_activities, auth=auth, headers=headers, timeout=20) + # if res_act.status_code == 200: + # act_data = res_act.json() + # activities_json.extend(act_data.get("activities", [])) + # url_activities = act_data.get("next_page_url") + # else: + # _logger.warning("Gagal fetch activities Freshservice (HTTP %s): %s", res_act.status_code, res_act.text) + # break + # except Exception as e: + # _logger.warning("Exception saat fetch activities halaman %s: %s", page_count, e) + # break + + # # 3. Conversations + # url_conv = f"{self.base_url}/api/v2/tickets/{ticket_id}/conversations" + # res_conv = requests.get(url_conv, auth=auth, headers=headers, timeout=20) + # conversations_json = res_conv.json().get("conversations", []) if res_conv.status_code == 200 else [] + + # return { + # "ticket": ticket_json, + # "activities": activities_json, + # "conversations": conversations_json, + # } + def _get_live_payload(self, ticket_id): + if not self.base_url or not self.api_key: + raise UserError( + "Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!" + ) + + auth = (self.api_key, "X") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + # 1. Ticket details & stats with requester + url_ticket = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}?include=requester,stats" + ) + + res_ticket = requests.get( + url_ticket, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_ticket.status_code != 200: + raise UserError( + f"Gagal mengambil tiket dari Freshservice " + f"(HTTP {res_ticket.status_code}): {res_ticket.text}" + ) + + ticket_json = res_ticket.json().get("ticket", {}) + + # Resolusi data pemohon (requester), agen (responder), dan departemen + req = ticket_json.get("requester") or {} + + ticket_json["requester_name"] = ( + req.get("name") + or ticket_json.get("requester_name") + or "" + ) + + ticket_json["requester_email"] = ( + req.get("email") + or ticket_json.get("requester_email") + or ticket_json.get("email") + or "" + ) + + resp_id = ticket_json.get("responder_id") + if resp_id: + ticket_json["responder_name"] = self.get_agent_name(resp_id) + + dept_id = ticket_json.get("department_id") + if dept_id: + ticket_json["department_name"] = self.get_department_name(dept_id) + elif ticket_json.get("custom_fields", {}).get( + "company_information_for_owner_of_ticket" + ): + ticket_json["department_name"] = ticket_json["custom_fields"][ + "company_information_for_owner_of_ticket" + ] + + # 2. Activities (Audit Trail) + # Fetch seluruh halaman sampai next_page_url kosong + url_activities = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}/activities" + ) + + activities_json = [] + visited_urls = set() + + while url_activities: + # Mencegah infinite loop jika URL pagination berulang + if url_activities in visited_urls: + _logger.warning( + "Pagination activities dihentikan karena URL berulang: %s", + url_activities, + ) + break + + visited_urls.add(url_activities) + + try: + res_act = requests.get( + url_activities, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_act.status_code != 200: + _logger.warning( + "Gagal fetch activities Freshservice (HTTP %s): %s", + res_act.status_code, + res_act.text, + ) + break + + act_data = res_act.json() + + # Tambahkan activities dari halaman saat ini + activities_json.extend( + act_data.get("activities", []) + ) + + # Lanjut sampai halaman terakhir + url_activities = act_data.get("next_page_url") + + except Exception as e: + _logger.warning( + "Exception saat fetch activities Freshservice: %s", + e, + ) + break + + _logger.info( + "Freshservice ticket %s: berhasil mengambil %s activities", + ticket_id, + len(activities_json), + ) + + # 3. Conversations + url_conv = ( + f"{self.base_url}/api/v2/tickets/" + f"{ticket_id}/conversations" + ) + + conversations_json = [] + + try: + res_conv = requests.get( + url_conv, + auth=auth, + headers=headers, + timeout=20, + ) + + if res_conv.status_code == 200: + conversations_json = res_conv.json().get( + "conversations", [] + ) + else: + _logger.warning( + "Gagal fetch conversations Freshservice " + "(HTTP %s): %s", + res_conv.status_code, + res_conv.text, + ) + + except Exception as e: + _logger.warning( + "Exception saat fetch conversations Freshservice: %s", + e, + ) + + return { + "ticket": ticket_json, + "activities": activities_json, + "conversations": conversations_json, + } + + def _get_mock_payload(self, ticket_id): + """ + Data Mock standar enterprise sesuai dokumentasi Freshservice API v2. + Merefleksikan siklus tiket riil: + - Open (08:30) + - First Response oleh Agent (08:52) -> jeda 22 menit + - Status Pending User (09:30) -> menunggu klarifikasi user + - User Balas (Keesokan hari 14:00) -> 28.5 jam di user + - Status In Progress (14:00) -> 2.5 jam di internal + - Status Resolved (16:30) + """ + str_id = str(ticket_id or "129") + + return { + "ticket": { + "id": int(str_id) if str_id.isdigit() else 129, + "subject": f"Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #{str_id}]", + "description_text": "Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.", + "status": 4, # 4 = Resolved + "priority": 3, # 3 = High + "source": 2, # Portal + "requester_id": 19000123456, + "requester_name": "Ahmad Dani (User)", + "requester_email": "ahmad.dani@lyman.co.id", + "responder_id": 19000234567, + "responder_name": "Budi Santoso (IT Support)", + "responder_email": "budi.santoso@lyman.co.id", + "department_name": "Finance & Accounting", + "created_at": "2026-10-10T08:30:00Z", + "updated_at": "2026-10-11T16:30:00Z", + "due_by": "2026-10-12T17:00:00Z", + "stats": { + "first_responded_at": "2026-10-10T08:52:00Z", + "resolved_at": "2026-10-11T16:30:00Z", + "closed_at": None, + "pending_since": "2026-10-10T09:30:00Z", + } + }, + "activities": [ + { + "id": 101, + "actor": { + "id": 19000123456, + "name": "Ahmad Dani (User)", + "is_agent": False + }, + "actor_id": 19000123456, + "actor_type": "Requester", + "actor_name": "Ahmad Dani (User)", + "content": "Tiket dibuat via Web Portal dengan status Open", + "stage_name": "Open", + "responsibility": "internal", + "created_at": "2026-10-10T08:30:00Z" + }, + { + "id": 102, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.", + "stage_name": "In Progress", + "responsibility": "internal", + "created_at": "2026-10-10T08:52:00Z" + }, + { + "id": 103, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.", + "stage_name": "Pending User", + "responsibility": "customer", + "created_at": "2026-10-10T09:30:00Z" + }, + { + "id": 104, + "actor": { + "id": 19000123456, + "name": "Ahmad Dani (User)", + "is_agent": False + }, + "actor_id": 19000123456, + "actor_type": "Requester", + "actor_name": "Ahmad Dani (User)", + "content": "User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.", + "stage_name": "In Progress", + "responsibility": "internal", + "created_at": "2026-10-11T14:00:00Z" + }, + { + "id": 105, + "actor": { + "id": 19000234567, + "name": "Budi Santoso (IT Support)", + "is_agent": True + }, + "actor_id": 19000234567, + "actor_type": "Agent", + "actor_name": "Budi Santoso (IT Support)", + "content": "Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.", + "stage_name": "Resolved", + "responsibility": "internal", + "created_at": "2026-10-11T16:30:00Z" + } + ], + "conversations": [ + { + "id": 501, + "body_text": "Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-10T08:52:00Z" + }, + { + "id": 502, + "body_text": "Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-10T09:30:00Z" + }, + { + "id": 503, + "body_text": "Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.", + "incoming": True, + "user_id": 19000123456, + "user_name": "Ahmad Dani (User)", + "created_at": "2026-10-11T14:00:00Z" + }, + { + "id": 504, + "body_text": "Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.", + "incoming": False, + "user_id": 19000234567, + "user_name": "Budi Santoso (IT Support)", + "created_at": "2026-10-11T16:30:00Z" + } + ] + } + + @staticmethod + def parse_datetime(iso_str): + """Konversi timestamp API ke string datetime UTC yang diterima Odoo. + + API dapat mengirim ISO string maupun objek ``datetime``. Keduanya + dinormalisasi ke format yang sama agar tidak terjadi perbandingan + ``str`` dengan ``datetime`` saat audit trail direkonstruksi. + """ + if not iso_str: + return False + try: + if isinstance(iso_str, datetime.datetime): + dt = iso_str + elif isinstance(iso_str, str): + clean = iso_str.strip().replace("Z", "+00:00") + dt = datetime.datetime.fromisoformat(clean) + else: + _logger.warning("Tipe datetime tidak didukung: %r", type(iso_str)) + return False + + # Konversi ke naive UTC untuk Odoo. + if dt.tzinfo is not None: + dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception as e: + _logger.warning("Gagal parse datetime '%s': %s", iso_str, e) + return False diff --git a/gmn_freshservice/views/gmn_freshservice_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 0000000..67d7498 Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc new file mode 100644 index 0000000..541d3ce Binary files /dev/null and b/gmn_freshservice/wizard/__pycache__/freshservice_ticket_bottleneck_wizard.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py new file mode 100644 index 0000000..5306235 --- /dev/null +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py @@ -0,0 +1,883 @@ +# -*- coding: utf-8 -*- +import io +import re +import base64 +import logging +from datetime import datetime +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import xlsxwriter +from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days, compute_working_hours, format_working_duration + +_logger = logging.getLogger(__name__) + +class FreshserviceTicketBottleneckWizard(models.TransientModel): + _name = 'freshservice.ticket.bottleneck.wizard' + _description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)' + + report_type = fields.Selection([ + ('single', 'Analisis Detail Bottleneck Tiket Tunggal (cth: #992)'), + ('ranking', 'Ranking Bottleneck Multi-Tiket (Bahan Meeting)'), + ], string='Tipe Laporan', default='single', required=True) + + ticket_id = fields.Many2one( + 'freshservice.ticket', + string='Pilih Tiket Odoo', + help='Pilih tiket yang sudah ada di database Odoo' + ) + + external_ticket_id = fields.Char( + string='Nomor ID Tiket Freshservice', + default='992', + help='Masukkan nomor ID Freshservice (misal: 992) untuk langsung mencari atau menarik data tiket tersebut.' + ) + + ticket_ids = fields.Many2many( + 'freshservice.ticket', + 'freshservice_wizard_ticket_rel', + 'wizard_id', + 'ticket_id', + string='Pilih Daftar Tiket Spesifik (Multi-Tiket)', + help='Kosongkan jika ingin mengambil tiket berdasarkan filter tanggal/status di bawah.' + ) + + date_from = fields.Date(string='Dari Tanggal Dibuat') + date_to = fields.Date(string='Sampai Tanggal Dibuat') + + status_filter = fields.Selection([ + ('all', 'Semua Status'), + ('open', 'Open'), + ('pending', 'Pending User'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ], string='Filter Status', default='all') + + auto_sync_detail = fields.Boolean( + string='Auto-Fetch Detail Freshservice', + default=True, + help='Jika dicentang, wizard akan otomatis mengambil rincian stage log dan percakapan terbaru dari Freshservice bila belum tersinkron.' + ) + + state = fields.Selection([ + ('choose', 'Pilih Parameter'), + ('get', 'Laporan Siap Diunduh'), + ], string='Status', default='choose') + + excel_file = fields.Binary(string='File Excel Laporan', readonly=True) + file_name = fields.Char(string='Nama File', readonly=True) + bottleneck_summary_html = fields.Html(string='Ringkasan Analisis Bottleneck', readonly=True) + + @api.onchange('ticket_id') + def _onchange_ticket_id(self): + if self.ticket_id and self.ticket_id.external_ticket_id: + self.external_ticket_id = self.ticket_id.external_ticket_id + + def _strip_html(self, text): + """Menghapus tag HTML agar teks di Excel bersih.""" + if not text: + return "" + clean = re.sub(r'<[^>]+>', ' ', text) + clean = re.sub(r'\s+', ' ', clean).strip() + return clean + + def _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:

+ + 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 + +