[ADD] iitial commit gmn fresh service
This commit is contained in:
4
gmn_freshservice/__init__.py
Normal file
4
gmn_freshservice/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import services
|
||||||
|
from . import wizard
|
||||||
34
gmn_freshservice/__manifest__.py
Normal file
34
gmn_freshservice/__manifest__.py
Normal file
@@ -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',
|
||||||
|
}
|
||||||
BIN
gmn_freshservice/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc
Normal file
Binary file not shown.
123
gmn_freshservice/data/gmn_demo_data_v01.xml
Normal file
123
gmn_freshservice/data/gmn_demo_data_v01.xml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- TIKET CONTOH FRESHSERVICE #129 -->
|
||||||
|
<record id="demo_freshservice_ticket_129" model="freshservice.ticket">
|
||||||
|
<field name="name">Kendala Koneksi Jaringan & SAP Client - Dept Finance</field>
|
||||||
|
<field name="external_ticket_id">129</field>
|
||||||
|
<field name="description">Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.</field>
|
||||||
|
<field name="status">resolved</field>
|
||||||
|
<field name="priority">high</field>
|
||||||
|
<field name="requester_name">Ahmad Dani (User)</field>
|
||||||
|
<field name="requester_email">ahmad.dani@lyman.co.id</field>
|
||||||
|
<field name="responder_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="department_name">Finance & Accounting</field>
|
||||||
|
<field name="ticket_created_at">2026-10-10 08:30:00</field>
|
||||||
|
<field name="due_by">2026-10-12 17:00:00</field>
|
||||||
|
<field name="first_responded_at">2026-10-10 08:52:00</field>
|
||||||
|
<field name="first_responder_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="resolved_at">2026-10-11 16:30:00</field>
|
||||||
|
<field name="is_mock_data">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- STAGE LOG 1: OPEN (INTERNAL) -->
|
||||||
|
<record id="demo_stage_log_1" model="freshservice.ticket.stage.log">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="stage_name">Open</field>
|
||||||
|
<field name="responsibility">internal</field>
|
||||||
|
<field name="actor_name">Ahmad Dani (User)</field>
|
||||||
|
<field name="actor_type">Requester</field>
|
||||||
|
<field name="activity_content">Tiket dibuat via Web Portal dengan status Open</field>
|
||||||
|
<field name="start_date">2026-10-10 08:30:00</field>
|
||||||
|
<field name="end_date">2026-10-10 08:52:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- STAGE LOG 2: FIRST RESPONSE / IN PROGRESS (INTERNAL) -->
|
||||||
|
<record id="demo_stage_log_2" model="freshservice.ticket.stage.log">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="stage_name">In Progress</field>
|
||||||
|
<field name="responsibility">internal</field>
|
||||||
|
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="actor_type">Agent</field>
|
||||||
|
<field name="activity_content">First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.</field>
|
||||||
|
<field name="start_date">2026-10-10 08:52:00</field>
|
||||||
|
<field name="end_date">2026-10-10 09:30:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- STAGE LOG 3: PENDING USER (CUSTOMER) -->
|
||||||
|
<record id="demo_stage_log_3" model="freshservice.ticket.stage.log">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="stage_name">Pending User</field>
|
||||||
|
<field name="responsibility">customer</field>
|
||||||
|
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="actor_type">Agent</field>
|
||||||
|
<field name="activity_content">Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.</field>
|
||||||
|
<field name="start_date">2026-10-10 09:30:00</field>
|
||||||
|
<field name="end_date">2026-10-11 14:00:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- STAGE LOG 4: IN PROGRESS KEMBALI SETELAH USER BALAS (INTERNAL) -->
|
||||||
|
<record id="demo_stage_log_4" model="freshservice.ticket.stage.log">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="stage_name">In Progress</field>
|
||||||
|
<field name="responsibility">internal</field>
|
||||||
|
<field name="actor_name">Ahmad Dani (User)</field>
|
||||||
|
<field name="actor_type">Requester</field>
|
||||||
|
<field name="activity_content">User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.</field>
|
||||||
|
<field name="start_date">2026-10-11 14:00:00</field>
|
||||||
|
<field name="end_date">2026-10-11 16:30:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- STAGE LOG 5: RESOLVED (INTERNAL) -->
|
||||||
|
<record id="demo_stage_log_5" model="freshservice.ticket.stage.log">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="stage_name">Resolved</field>
|
||||||
|
<field name="responsibility">internal</field>
|
||||||
|
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="actor_type">Agent</field>
|
||||||
|
<field name="activity_content">Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.</field>
|
||||||
|
<field name="start_date">2026-10-11 16:30:00</field>
|
||||||
|
<field name="end_date">2026-10-11 16:30:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- PERCAKAPAN 1 -->
|
||||||
|
<record id="demo_conversation_1" model="freshservice.ticket.conversation">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="external_id">501</field>
|
||||||
|
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="incoming">0</field>
|
||||||
|
<field name="body_text">Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.</field>
|
||||||
|
<field name="created_at">2026-10-10 08:52:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- PERCAKAPAN 2 -->
|
||||||
|
<record id="demo_conversation_2" model="freshservice.ticket.conversation">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="external_id">502</field>
|
||||||
|
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="incoming">0</field>
|
||||||
|
<field name="body_text">Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?</field>
|
||||||
|
<field name="created_at">2026-10-10 09:30:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- PERCAKAPAN 3 -->
|
||||||
|
<record id="demo_conversation_3" model="freshservice.ticket.conversation">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="external_id">503</field>
|
||||||
|
<field name="user_name">Ahmad Dani (User)</field>
|
||||||
|
<field name="incoming">1</field>
|
||||||
|
<field name="body_text">Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.</field>
|
||||||
|
<field name="created_at">2026-10-11 14:00:00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- PERCAKAPAN 4 -->
|
||||||
|
<record id="demo_conversation_4" model="freshservice.ticket.conversation">
|
||||||
|
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||||
|
<field name="external_id">504</field>
|
||||||
|
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||||
|
<field name="incoming">0</field>
|
||||||
|
<field name="body_text">Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.</field>
|
||||||
|
<field name="created_at">2026-10-11 16:30:00</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
360
gmn_freshservice/gmn_technical_design_document_v01.md
Normal file
360
gmn_freshservice/gmn_technical_design_document_v01.md
Normal file
@@ -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}`<br>`Accept: application/json` | `order_by=created_at`<br>`order_type=desc`<br>`per_page=100`<br>`page={1..N}`<br>`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.
|
||||||
|
|
||||||
5
gmn_freshservice/models/__init__.py
Normal file
5
gmn_freshservice/models/__init__.py
Normal file
@@ -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
|
||||||
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
||||||
363
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal file
363
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal file
@@ -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 = "-"
|
||||||
965
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal file
965
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal file
@@ -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 <b>%s</b>.", 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'},
|
||||||
|
}
|
||||||
|
}
|
||||||
71
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal file
71
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal file
@@ -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)}")
|
||||||
5
gmn_freshservice/security/gmn_ir_model_access_v01.csv
Normal file
5
gmn_freshservice/security/gmn_ir_model_access_v01.csv
Normal file
@@ -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
|
||||||
|
2
gmn_freshservice/services/__init__.py
Normal file
2
gmn_freshservice/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import gmn_freshservice_client_v01
|
||||||
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
637
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
637
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
@@ -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
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- LIST VIEW STAGE LOGS -->
|
||||||
|
<record id="view_freshservice_ticket_stage_log_tree" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.stage.log.tree</field>
|
||||||
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Audit Trail & Durasi Status"
|
||||||
|
decoration-danger="responsibility == 'internal' and is_active"
|
||||||
|
decoration-warning="responsibility == 'customer' and is_active"
|
||||||
|
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||||
|
<field name="ticket_id"/>
|
||||||
|
<field name="stage_name" widget="badge"/>
|
||||||
|
<field name="responsibility"
|
||||||
|
widget="badge"
|
||||||
|
decoration-danger="responsibility == 'internal'"
|
||||||
|
decoration-warning="responsibility == 'customer'"
|
||||||
|
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
|
||||||
|
<field name="actor_name"/>
|
||||||
|
<field name="actor_type"/>
|
||||||
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
|
<field name="end_date" string="Waktu Selesai (UTC)"/>
|
||||||
|
<field name="working_duration_display" string="Jam Kerja (08:30-17:30)"/>
|
||||||
|
<field name="working_days_effective" string="Hari Kerja Efektif"/>
|
||||||
|
<field name="duration_display" string="Durasi Kalender"/>
|
||||||
|
<field name="is_active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- FORM VIEW STAGE LOGS -->
|
||||||
|
<record id="view_freshservice_ticket_stage_log_form" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.stage.log.form</field>
|
||||||
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Detail Audit Trail">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="ticket_id" readonly="1"/>
|
||||||
|
<field name="stage_name" readonly="1"/>
|
||||||
|
<field name="responsibility" readonly="1"/>
|
||||||
|
<field name="actor_name" readonly="1"/>
|
||||||
|
<field name="actor_type" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
||||||
|
<field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/>
|
||||||
|
<field name="working_duration_display" readonly="1" string="Jam Kerja (08:30-17:30 WIB)"/>
|
||||||
|
<field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
|
||||||
|
<field name="duration_display" readonly="1" string="Durasi Kalender"/>
|
||||||
|
<field name="duration_days" readonly="1"/>
|
||||||
|
<!-- Jam di-hide -->
|
||||||
|
<field name="duration_hours" invisible="1"/>
|
||||||
|
<field name="is_active" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Deskripsi Aktivitas">
|
||||||
|
<field name="activity_content" readonly="1"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEARCH VIEW STAGE LOGS -->
|
||||||
|
<record id="view_freshservice_ticket_stage_log_search" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.stage.log.search</field>
|
||||||
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Cari Log Aktivitas">
|
||||||
|
<field name="ticket_id"/>
|
||||||
|
<field name="stage_name"/>
|
||||||
|
<field name="actor_name"/>
|
||||||
|
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
|
||||||
|
<filter name="filter_customer" string="Waiting on User (Customer)" domain="[('responsibility', '=', 'customer')]"/>
|
||||||
|
<filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/>
|
||||||
|
<filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/>
|
||||||
|
<filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/>
|
||||||
|
<filter name="filter_scheduled_meeting" string="Tunggu Bersama (Scheduled Meeting)" domain="[('responsibility', '=', 'scheduled_meeting')]"/>
|
||||||
|
<filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/>
|
||||||
|
<filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/>
|
||||||
|
<filter name="filter_sla_paused" string="Semua SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
|
||||||
|
<filter name="filter_active" string="Sedang Berjalan (Aktif)" domain="[('is_active', '=', True)]"/>
|
||||||
|
<separator/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
||||||
|
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'responsibility'}"/>
|
||||||
|
<filter name="group_by_stage" string="Status / Stage" context="{'group_by': 'stage_name'}"/>
|
||||||
|
<filter name="group_by_actor" string="Pelaku (Actor)" context="{'group_by': 'actor_name'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ACTION STAGE LOGS -->
|
||||||
|
<record id="action_freshservice_ticket_stage_log" model="ir.actions.act_window">
|
||||||
|
<field name="name">Audit Trail Riwayat Status</field>
|
||||||
|
<field name="res_model">freshservice.ticket.stage.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_freshservice_ticket_stage_log_search"/>
|
||||||
|
<field name="context">{'search_default_group_by_ticket': 1}</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
334
gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml
Normal file
334
gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- FORM VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_form" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.form</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Freshservice Ticket Audit Trail">
|
||||||
|
<header>
|
||||||
|
<button name="action_sync_from_freshservice"
|
||||||
|
type="object"
|
||||||
|
string="Tarik Data Freshservice"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-refresh"/>
|
||||||
|
<button name="action_open_bottleneck_wizard"
|
||||||
|
type="object"
|
||||||
|
string="Laporan Bottleneck (Excel)"
|
||||||
|
class="btn-outline-success"
|
||||||
|
icon="fa-file-excel-o"/>
|
||||||
|
<!-- <button name="action_assign_to_me"
|
||||||
|
type="object"
|
||||||
|
string="Tugaskan ke Saya"
|
||||||
|
class="btn-outline-primary"
|
||||||
|
icon="fa-user-check"/>
|
||||||
|
<button name="action_toggle_mock_mode"
|
||||||
|
type="object"
|
||||||
|
string="Ganti Mode (Mock / Live)"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-exchange"/> -->
|
||||||
|
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<field name="is_detail_synced" invisible="1"/>
|
||||||
|
<widget name="web_ribbon" title="SIMULASI MOCK DATA" bg_color="text-bg-info" invisible="not is_mock_data"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" string="Judul / Masalah Tiket"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span class="badge text-bg-secondary fs-6">ID Freshservice: #<field name="external_ticket_id" class="oe_inline"/></span>
|
||||||
|
<field name="current_responsibility" widget="badge"
|
||||||
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI METRIC CARDS SECTION -->
|
||||||
|
<div class="row mt-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-primary">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
||||||
|
<h4 class="mb-0 text-primary mt-1">
|
||||||
|
<field name="first_response_time_display" readonly="1"/>
|
||||||
|
</h4>
|
||||||
|
<small class="text-secondary mt-1">
|
||||||
|
Oleh: <field name="first_responder_name" readonly="1"/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-danger">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
||||||
|
<h4 class="mb-0 text-danger mt-1">
|
||||||
|
<field name="total_agent_time_display" readonly="1"/>
|
||||||
|
</h4>
|
||||||
|
<small class="text-secondary mt-1">Waiting on Us</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-warning">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">User</small>
|
||||||
|
<h4 class="mb-0 text-warning mt-1">
|
||||||
|
<field name="total_customer_time_display" readonly="1"/>
|
||||||
|
</h4>
|
||||||
|
<small class="text-secondary mt-1">Waiting on Customer</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-success">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
||||||
|
<h4 class="mb-0 text-success mt-1">
|
||||||
|
<field name="resolution_time_display" readonly="1"/>
|
||||||
|
</h4>
|
||||||
|
<small class="text-secondary mt-1">Total Resolution</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Informasi Pelapor & Penugasan">
|
||||||
|
<field name="requester_name"/>
|
||||||
|
<field name="requester_email"/>
|
||||||
|
<field name="responder_name"/>
|
||||||
|
<field name="department_name"/>
|
||||||
|
</group>
|
||||||
|
<group string="Parameter Tiket">
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
<field name="ticket_created_at" readonly="1"/>
|
||||||
|
<field name="due_by" readonly="1"/>
|
||||||
|
<field name="is_mock_data" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Detail Flow & Durasi Status" name="audit_trail">
|
||||||
|
<div class="alert alert-info py-2" role="alert">
|
||||||
|
<strong>Log Transparansi SLA:</strong> Tabel ini menghitung secara objektif berapa lama tiket berada di tangan agen internal vs menunggu konfirmasi user di setiap pergantian status.
|
||||||
|
</div>
|
||||||
|
<field name="stage_log_ids" readonly="1">
|
||||||
|
<list decoration-danger="responsibility == 'internal' and is_active"
|
||||||
|
decoration-warning="responsibility == 'customer' and is_active"
|
||||||
|
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||||
|
<field name="stage_name" widget="badge"/>
|
||||||
|
<field name="responsibility" widget="badge"
|
||||||
|
decoration-danger="responsibility == 'internal'"
|
||||||
|
decoration-warning="responsibility == 'customer'"
|
||||||
|
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
|
||||||
|
<field name="actor_name"/>
|
||||||
|
<field name="actor_type"/>
|
||||||
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
|
<field name="end_date" string="Waktu Selesai (UTC)"/>
|
||||||
|
<field name="working_duration_display" string="Jam Kerja (08:30-17:30)"/>
|
||||||
|
<field name="working_days_effective" string="Hari Kerja Efektif"/>
|
||||||
|
<field name="activity_content"/>
|
||||||
|
<field name="is_active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Percakapan & Balasan" name="conversations">
|
||||||
|
<field name="conversation_ids" readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="created_at"/>
|
||||||
|
<field name="sender_type" widget="badge"
|
||||||
|
decoration-info="sender_type == 'agent'"
|
||||||
|
decoration-success="sender_type == 'customer'"/>
|
||||||
|
<field name="user_name"/>
|
||||||
|
<field name="body_text"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Deskripsi Masalah" name="description">
|
||||||
|
<field name="description"/>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Timestamp Teknis" name="technical_timestamps">
|
||||||
|
<group>
|
||||||
|
<group string="Waktu Kejadian">
|
||||||
|
<field name="ticket_created_at" readonly="1"/>
|
||||||
|
<field name="first_responded_at" readonly="1"/>
|
||||||
|
<field name="resolved_at" readonly="1"/>
|
||||||
|
<field name="last_detail_sync" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Metrik SLA (Hari)">
|
||||||
|
<field name="first_response_time_days" readonly="1"/>
|
||||||
|
<field name="total_agent_time_days" readonly="1"/>
|
||||||
|
<field name="total_customer_time_days" readonly="1"/>
|
||||||
|
<field name="resolution_time_days" readonly="1"/>
|
||||||
|
<!-- Field jam disembunyikan (hide) -->
|
||||||
|
<field name="first_response_time_hours" invisible="1"/>
|
||||||
|
<field name="total_agent_time_hours" invisible="1"/>
|
||||||
|
<field name="total_customer_time_hours" invisible="1"/>
|
||||||
|
<field name="resolution_time_hours" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- LIST VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_tree" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.tree</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Daftar Tiket Freshservice"
|
||||||
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
|
decoration-muted="status in ('resolved', 'closed')">
|
||||||
|
<header>
|
||||||
|
<button name="action_sync_recent_tickets"
|
||||||
|
string="Tarik Tiket Terbaru Freshservice"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-cloud-download"/>
|
||||||
|
</header>
|
||||||
|
<field name="external_ticket_id"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="status" widget="badge"
|
||||||
|
decoration-info="status == 'open'"
|
||||||
|
decoration-warning="status == 'pending'"
|
||||||
|
decoration-success="status == 'resolved'"
|
||||||
|
decoration-muted="status == 'closed'"/>
|
||||||
|
<field name="current_responsibility" widget="badge"
|
||||||
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
|
<field name="total_agent_time_display"/>
|
||||||
|
<field name="total_customer_time_display"/>
|
||||||
|
<field name="first_response_time_display"/>
|
||||||
|
<field name="responder_name"/>
|
||||||
|
<field name="requester_name"/>
|
||||||
|
<field name="ticket_created_at"/>
|
||||||
|
<field name="is_detail_synced" widget="boolean_toggle" optional="hide" string="Detail Di-fetch"/>
|
||||||
|
<field name="is_mock_data" widget="boolean_toggle" optional="show"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- KANBAN VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.kanban</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="status" class="o_kanban_small_column">
|
||||||
|
<field name="id"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="external_ticket_id"/>
|
||||||
|
<field name="priority"/>
|
||||||
|
<field name="current_responsibility"/>
|
||||||
|
<field name="total_agent_time_display"/>
|
||||||
|
<field name="total_customer_time_display"/>
|
||||||
|
<field name="responder_name"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="kanban-box">
|
||||||
|
<div class="oe_kanban_global_click p-3 rounded">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="badge text-bg-light fw-bold">#<field name="external_ticket_id"/></span>
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
</div>
|
||||||
|
<div class="fw-bold mt-2">
|
||||||
|
<field name="name"/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center text-muted small mt-1">
|
||||||
|
<span>Agen: <field name="responder_name"/></span>
|
||||||
|
<field name="user_id" widget="many2one_avatar_user"/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
|
||||||
|
<span class="badge text-bg-danger">Agen: <field name="total_agent_time_display"/></span>
|
||||||
|
<span class="badge text-bg-warning">User: <field name="total_customer_time_display"/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- PIVOT VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_pivot" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.pivot</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<pivot string="Analisis Durasi Tiket SLA (Hari)">
|
||||||
|
<field name="responder_name" type="row"/>
|
||||||
|
<field name="current_responsibility" type="col"/>
|
||||||
|
<field name="total_agent_time_days" type="measure"/>
|
||||||
|
<field name="total_customer_time_days" type="measure"/>
|
||||||
|
<field name="first_response_time_days" type="measure"/>
|
||||||
|
<field name="resolution_time_days" type="measure"/>
|
||||||
|
</pivot>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- GRAPH VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_graph" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.graph</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<graph string="Perbandingan Durasi Agen vs User (Hari)" type="bar">
|
||||||
|
<field name="responder_name"/>
|
||||||
|
<field name="total_agent_time_days" type="measure"/>
|
||||||
|
<field name="total_customer_time_days" type="measure"/>
|
||||||
|
</graph>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEARCH VIEW -->
|
||||||
|
<record id="view_freshservice_ticket_search" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.search</field>
|
||||||
|
<field name="model">freshservice.ticket</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Cari Tiket">
|
||||||
|
<field name="name" filter_domain="['|', ('name', 'ilike', self), ('external_ticket_id', 'ilike', self)]"/>
|
||||||
|
<field name="external_ticket_id"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="requester_name"/>
|
||||||
|
<field name="responder_name"/>
|
||||||
|
<filter name="filter_my_tickets" string="Tiket Saya" domain="[('user_id', '=', uid)]"/>
|
||||||
|
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
|
||||||
|
<filter name="filter_waiting_user" string="Waiting on Customer (User)" domain="[('current_responsibility', '=', 'customer')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_open_progress" string="Tiket Aktif (Belum Selesai)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
||||||
|
<filter name="filter_resolved" string="Selesai (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
|
||||||
|
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
||||||
|
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/>
|
||||||
|
<filter name="group_by_responder" string="Agen (IT)" context="{'group_by': 'responder_name'}"/>
|
||||||
|
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ACTIONS -->
|
||||||
|
<record id="action_freshservice_ticket" model="ir.actions.act_window">
|
||||||
|
<field name="name">Daftar Tiket</field>
|
||||||
|
<field name="res_model">freshservice.ticket</field>
|
||||||
|
<field name="view_mode">list,form,kanban,pivot,graph</field>
|
||||||
|
<field name="search_view_id" ref="view_freshservice_ticket_search"/>
|
||||||
|
<field name="context">{'search_default_filter_open_progress': 0}</field>
|
||||||
|
<field name="limit">200</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Belum ada tiket Freshservice yang dimuat!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Klik tombol <strong>"Tarik Tiket Terbaru Freshservice"</strong> pada header tabel untuk menarik daftar tiket dari Freshservice secara otomatis.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
49
gmn_freshservice/views/gmn_menu_views_v01.xml
Normal file
49
gmn_freshservice/views/gmn_menu_views_v01.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- TOP LEVEL ROOT MENU -->
|
||||||
|
<menuitem id="menu_freshservice_root"
|
||||||
|
name="Ticket Audit"
|
||||||
|
web_icon="fa fa-clock-o,#34495e,#ffffff"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<!-- SERVER ACTION: Buka Menu Sekaligus Auto-Fetch Data Terbaru Freshservice -->
|
||||||
|
<record id="action_server_open_freshservice_tickets" model="ir.actions.server">
|
||||||
|
<field name="name">Buka & Auto-Fetch Tiket Freshservice</field>
|
||||||
|
<field name="model_id" ref="model_freshservice_ticket"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
action = model.action_open_and_fetch_tickets()
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- MAIN MENUS -->
|
||||||
|
<menuitem id="menu_freshservice_tickets"
|
||||||
|
name="Daftar Tiket"
|
||||||
|
parent="menu_freshservice_root"
|
||||||
|
action="action_server_open_freshservice_tickets"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_freshservice_stage_logs"
|
||||||
|
name="Audit Trail Riwayat Status"
|
||||||
|
parent="menu_freshservice_root"
|
||||||
|
action="action_freshservice_ticket_stage_log"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_freshservice_bottleneck_wizard"
|
||||||
|
name="Laporan Bottleneck (Excel)"
|
||||||
|
parent="menu_freshservice_root"
|
||||||
|
action="action_freshservice_ticket_bottleneck_wizard"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<!-- CONFIGURATION MENU -->
|
||||||
|
<menuitem id="menu_freshservice_config_parent"
|
||||||
|
name="Konfigurasi"
|
||||||
|
parent="menu_freshservice_root"
|
||||||
|
sequence="90"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_freshservice_config_settings"
|
||||||
|
name="Pengaturan Integrasi"
|
||||||
|
parent="menu_freshservice_config_parent"
|
||||||
|
action="action_freshservice_config_settings"
|
||||||
|
sequence="10"/>
|
||||||
|
</odoo>
|
||||||
47
gmn_freshservice/views/gmn_res_config_settings_views_v01.xml
Normal file
47
gmn_freshservice/views/gmn_res_config_settings_views_v01.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form_freshservice" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.freshservice</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="priority" eval="90"/>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Freshservice Audit" string="Freshservice Audit" name="freshservice_ticket_audit">
|
||||||
|
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
|
||||||
|
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
||||||
|
<field name="freshservice_url"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting id="freshservice_api_key_setting" help="API Key dari profil Freshservice (wajib diisi untuk Live API)">
|
||||||
|
<field name="freshservice_api_key" password="True"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting id="freshservice_sync_limit_setting" help="Batas jumlah tiket terbaru yang ditarik saat membuka daftar tiket (default: 200)">
|
||||||
|
<field name="freshservice_sync_limit"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting id="freshservice_mock_setting" help="Gunakan mock data sesuai format resmi Freshservice API untuk testing tanpa API Key asli">
|
||||||
|
<field name="freshservice_use_mock"/>
|
||||||
|
<div class="mt8">
|
||||||
|
<button name="action_test_freshservice_connection"
|
||||||
|
type="object"
|
||||||
|
string="Tes Koneksi / Cek Status"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-plug"/>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_freshservice_config_settings" model="ir.actions.act_window">
|
||||||
|
<field name="name">Pengaturan Freshservice</field>
|
||||||
|
<field name="res_model">res.config.settings</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">main</field>
|
||||||
|
<field name="context">{'module': 'freshservice_ticket_audit'}</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
gmn_freshservice/wizard/__init__.py
Normal file
2
gmn_freshservice/wizard/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
||||||
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -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"""
|
||||||
|
<div class="alert alert-info border shadow-sm">
|
||||||
|
<h4 class="alert-heading fw-bold mb-2">📊 Hasil Analisis Bottleneck Tiket #{t.external_ticket_id}</h4>
|
||||||
|
<p class="mb-1"><strong>Subjek:</strong> {t.name or '-'}</p>
|
||||||
|
<p class="mb-2"><strong>Pemohon:</strong> {t.requester_name or '-'} | <strong>Departemen:</strong> {t.department_name or '-'} | <strong>Agen IT:</strong> {t.responder_name or '-'}</p>
|
||||||
|
<hr class="my-2"/>
|
||||||
|
<div class="row text-center mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="bg-light p-2 rounded border">
|
||||||
|
<small class="text-muted d-block">TOTAL WAKTU</small>
|
||||||
|
<strong class="text-dark fs-5">{t.resolution_time_days} Hari</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="bg-light p-2 rounded border border-danger">
|
||||||
|
<small class="text-danger d-block fw-bold">WAITING ON US (IT)</small>
|
||||||
|
<strong class="text-danger fs-5">{t.total_agent_time_days} Hari</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="bg-light p-2 rounded border border-warning">
|
||||||
|
<small class="text-warning d-block fw-bold">WAITING ON USER</small>
|
||||||
|
<strong class="text-warning fs-5">{t.total_customer_time_days} Hari</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="bg-light p-2 rounded border border-primary">
|
||||||
|
<small class="text-primary d-block fw-bold">FIRST RESPONSE</small>
|
||||||
|
<strong class="text-primary fs-5">{t.first_response_time_days} Hari</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
# <div class="alert alert-danger mb-0">
|
||||||
|
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
|
||||||
|
# {primary_bn_text}
|
||||||
|
# </div>
|
||||||
|
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"""
|
||||||
|
<div class="alert alert-info border shadow-sm">
|
||||||
|
<h4 class="alert-heading fw-bold mb-2">📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat</h4>
|
||||||
|
<p class="mb-1">Total Tiket yang Dievaluasi: <strong>{len(sorted_tickets)} Tiket</strong></p>
|
||||||
|
<p class="mb-2">Rata-rata Waktu Penyelesaian: <strong>{avg_days:.1f} Hari Kalender</strong></p>
|
||||||
|
<hr class="my-2"/>
|
||||||
|
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tiket dengan Durasi Terlama:</p>
|
||||||
|
<ul class="mb-2">
|
||||||
|
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
|
||||||
|
</ul>
|
||||||
|
<small class="text-muted">File Excel siap digunakan sebagai bahan evaluasi dan meeting manajemen.</small>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return html_out
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- FORM VIEW WIZARD BOTTLENECK -->
|
||||||
|
<record id="view_freshservice_ticket_bottleneck_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">freshservice.ticket.bottleneck.wizard.form</field>
|
||||||
|
<field name="model">freshservice.ticket.bottleneck.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Laporan Analisis Bottleneck Tiket (Excel)">
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
|
||||||
|
<!-- STATE 1: PILIH PARAMETER -->
|
||||||
|
<div invisible="state != 'choose'">
|
||||||
|
<div class="alert alert-primary mb-3 d-flex align-items-center gap-2" role="status" invisible="True">
|
||||||
|
<i class="fa fa-info-circle fa-2x" title="Informasi"/>
|
||||||
|
<div>
|
||||||
|
<strong>Ekspor Laporan Analisis Bottleneck ke Excel (.xlsx)</strong><br/>
|
||||||
|
Laporan ini dirancang khusus sebagai <em>bahan meeting</em> untuk mengidentifikasi tahapan, penanggung jawab, dan faktor yang menyebabkan tiket tertahan lama (Waiting on Us vs Waiting on User).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group invisible="True">
|
||||||
|
<group string="Pilihan Laporan">
|
||||||
|
<field name="report_type" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- MODE SINGLE TICKET -->
|
||||||
|
<!-- <group string="Parameter Tiket Tunggal" invisible="report_type != 'single'"> -->
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="external_ticket_id" placeholder="Contoh: 992"/>
|
||||||
|
<field name="ticket_id" options="{'no_create': True}" placeholder="Atau cari judul tiket di Odoo..."/>
|
||||||
|
</group>
|
||||||
|
<group invisible="True">
|
||||||
|
<field name="auto_sync_detail"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- MODE RANKING MULTI-TIKET -->
|
||||||
|
<group string="Filter Multi-Tiket (Ranking)" invisible="report_type != 'ranking'">
|
||||||
|
<group>
|
||||||
|
<field name="date_from"/>
|
||||||
|
<field name="date_to"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="status_filter"/>
|
||||||
|
<field name="auto_sync_detail"/>
|
||||||
|
</group>
|
||||||
|
<group colspan="2">
|
||||||
|
<field name="ticket_ids" widget="many2many_tags" placeholder="Kosongkan untuk menyertakan seluruh tiket sesuai filter di atas"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- FOOTER BUTTONS -->
|
||||||
|
<footer>
|
||||||
|
<div invisible="state != 'choose'" class="d-flex flex-row gap-2 justify-content-end">
|
||||||
|
<button name="action_generate_excel"
|
||||||
|
string="Export"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-file-excel-o"/>
|
||||||
|
<button string="Batal"
|
||||||
|
class="btn-secondary"
|
||||||
|
special="cancel"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ACTION WINDOW WIZARD (POP-UP MODAL) -->
|
||||||
|
<record id="action_freshservice_ticket_bottleneck_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Laporan Analisis Bottleneck Excel</field>
|
||||||
|
<field name="res_model">freshservice.ticket.bottleneck.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
<field name="binding_model_id" ref="model_freshservice_ticket"/>
|
||||||
|
<field name="binding_view_types">form,list</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user