Compare commits

...

6 Commits

Author SHA1 Message Date
Madanil
e1c6b39444 [UPD] update role access 2026-10-27 17:14:34 +07:00
Madanil
24c7ea9447 [UPD] update logic fdd tdd 2026-10-21 16:54:17 +07:00
Madanil
dd44452fa1 [UPD] update flow input hlidays ind 2026-10-20 14:39:32 +07:00
Madanil
c0a185478b [UPD] add dokumentasi fucntion 2026-10-20 08:57:07 +07:00
Madanil
62e545276f [UPD] update logic tiket 2026-10-19 16:46:27 +07:00
Madanil
ef5ba7f159 [UPD] update colot template and logic UI 2026-10-19 10:15:38 +07:00
41 changed files with 1937 additions and 837 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
docs
gmn_freshservice/tests

126
README.md Normal file
View File

@@ -0,0 +1,126 @@
# Generator Laporan Analisis Tiket (GMN Freshservice)
| Atribut | Nilai |
|---|---|
| **Versi** | 1.0.0 |
| **Tanggal** | 20 Oktober 2026 |
| **Status** | Disetujui (Approved) |
| **Pembaca** | Semua Tim (Manajemen, Service Desk, Developer, QA) |
| **Pembaruan Terakhir** | Saat penyesuaian aturan klasifikasi SLA |
> **Intinya dalam 3 kalimat:**
> Aplikasi ini membaca riwayat aktivitas (audit trail) tiket Freshservice dan mengubahnya menjadi laporan Excel 3 sheet yang adil dan transparan. Sistem menghitung secara otomatis berapa lama waktu pengerjaan di pihak IT, waktu tunggu di pihak pemohon/user, waktu vendor, dan waktu rapat bersama. Dengan laporan ini, evaluasi SLA tidak lagi saling menyalahkan karena setiap menit tercatat lengkap dengan bukti pesan dan statusnya.
---
## 1. Daftar Dokumen & Panduan Membaca
Seluruh dokumentasi teknis, bisnis, operasional, dan pengujian tersimpan rapi di dalam folder `docs/`:
| Kategori | Dokumen Kunci | Keterangan |
|---|---|---|
| **Daftar Isi & Indeks** | [`docs/00-INDEX.md`](docs/00-INDEX.md) | Peta navigasi seluruh dokumen dan urutan baca pemula |
| **Bisnis & Kebutuhan** | [`docs/01-bisnis/BRD.md`](docs/01-bisnis/BRD.md)<br>[`docs/02-kebutuhan/SRS.md`](docs/02-kebutuhan/SRS.md)<br>[`docs/02-kebutuhan/Katalog-Aturan-Bisnis.md`](docs/02-kebutuhan/Katalog-Aturan-Bisnis.md) | Alasan proyek dibuat, spesifikasi kebutuhan FR/NFR, dan katalog aturan RULE-01 s/d RULE-12 |
| **Desain Teknis & Data** | [`docs/03-desain/FDD.md`](docs/03-desain/FDD.md)<br>[`docs/03-desain/TDD.md`](docs/03-desain/TDD.md)<br>[`docs/03-desain/Arsitektur.md`](docs/03-desain/Arsitektur.md)<br>[`docs/03-desain/Kamus-Data-dan-DFD.md`](docs/03-desain/Kamus-Data-dan-DFD.md)<br>[`docs/03-desain/ADR/`](docs/03-desain/ADR/) | Desain fungsional, arsitektur modul Odoo, kamus kolom Excel, dan catatan keputusan arsitektur (ADR-01 s/d ADR-03) |
| **Pengujian & Validasi** | [`docs/04-pengujian/Test-Plan.md`](docs/04-pengujian/Test-Plan.md)<br>[`docs/04-pengujian/Test-Case.md`](docs/04-pengujian/Test-Case.md)<br>[`docs/04-pengujian/Laporan-Validasi.md`](docs/04-pengujian/Laporan-Validasi.md)<br>[`docs/04-pengujian/UAT.md`](docs/04-pengujian/UAT.md) | Rencana uji, skenario uji TC-01 s/d TC-08, bukti validasi Tiket #9658, dan lembar UAT pengguna |
| **Operasional & Panduan** | [`docs/05-operasional/Panduan-Pengguna.md`](docs/05-operasional/Panduan-Pengguna.md)<br>[`docs/05-operasional/SOP-Status-Tiket.md`](docs/05-operasional/SOP-Status-Tiket.md)<br>[`docs/05-operasional/Runbook.md`](docs/05-operasional/Runbook.md)<br>[`docs/05-operasional/Konfigurasi.md`](docs/05-operasional/Konfigurasi.md) | Panduan langkah demi langkah agen/supervisor, SOP pemilihan status, instalasi, dan parameter konfigurasi |
| **Manajemen & Tata Kelola**| [`docs/06-manajemen/Risiko-dan-Known-Issues.md`](docs/06-manajemen/Risiko-dan-Known-Issues.md)<br>[`docs/06-manajemen/Change-Request-Perbaikan-Logic.md`](docs/06-manajemen/Change-Request-Perbaikan-Logic.md)<br>[`docs/06-manajemen/Keamanan-dan-Privasi.md`](docs/06-manajemen/Keamanan-dan-Privasi.md)<br>[`docs/06-manajemen/Traceability-Matrix.md`](docs/06-manajemen/Traceability-Matrix.md)<br>[`docs/06-manajemen/Roadmap.md`](docs/06-manajemen/Roadmap.md)<br>[`docs/06-manajemen/Glosarium.md`](docs/06-manajemen/Glosarium.md)<br>[`CHANGELOG.md`](CHANGELOG.md) | Daftar risiko, analisis dampak CR-01, matriks ketelusuran BR-FR-RULE-TC, peta jalan rilis, dan glosarium istilah |
---
## 2. Fitur Utama
1. **Two-Tier Synchronization**: Tarik daftar tiket secara instan tanpa freeze, dan ambil rincian lengkap (aktivitas audit trail + riwayat chat) secara on-demand saat tiket dibuka.
2. **Mesin Hitung Jam Kerja Efektif**:
- Hari Kerja: Senin – Jumat (08:30 – 17:30 WIB).
- Istirahat: 12:30 – 13:30 WIB (tidak dihitung sebagai jam kerja).
- Libur Nasional: Terintegrasi dengan kalender libur Indonesia (2024–2026) dan kalender Odoo.
- Konversi 8 jam kerja efektif = 1 hari kerja.
3. **Klasifikasi Tanggung Jawab Adil (Fair Responsibility Logic)**:
- Membagi waktu tiket ke dalam 8 kategori (*bucket*).
- Deteksi otomatis balasan agen (*Ball-in-Court Analysis*) untuk menandai status ambigu (`needs_verification`).
- Deteksi undangan rapat Zoom/Teams otomatis ke bucket *Scheduled Meeting (Bersama)*.
- Pemisahan durasi *Resolved* oleh bot otomatis sistem (`user_after_auto_resolve`) agar tidak menyalahkan user secara keliru.
4. **Generator Laporan Excel 3 Sheet**:
- **Sheet 1 (Ringkasan & Bottleneck KPI)**: Kartu KPI, breakdown persentase tanggung jawab, dan tabel ranking segmen bottleneck terlama.
- **Sheet 2 (Audit Trail & Chronology)**: Rekonstruksi kronologis setiap menit aktivitas tiket dengan formula total otomatis.
- **Sheet 3 (Conversations & Notes)**: Transkrip lengkap percakapan dan catatan tiket antara agen dan pemohon.
5. **Dukungan Multi-Tiket**: Ekspor perbandingan ranking bottleneck untuk bahan rapat koordinasi mingguan tim Service Desk.
---
## 3. Panduan Cepat (Quick Start dalam 15 Menit)
### 3.1 Prasyarat
- Odoo 19.0 Community / Enterprise (Python 3.10+)
- Modul python `xlsxwriter` dan `requests` terpasang di environment Odoo
- Akun admin Freshservice dengan hak akses API (API Key)
### 3.2 Konfigurasi Koneksi
1. Masuk ke Odoo dengan hak akses Administrator.
2. Buka menu **Settings** > **Freshservice Configuration**.
3. Masukkan URL Freshservice (contoh: `https://servicedesk.perusahaan.co.id`) dan **API Key**.
4. Atur batas sinkronisasi (default: `200` tiket).
5. Klik tombol **Uji Koneksi** untuk memastikan status terhubung.
### 3.3 Menjalankan Sinkronisasi & Analisis
1. Buka menu **Freshservice** > **Daftar Tiket**.
2. Klik tombol **Tarik Tiket Terbaru** pada bagian atas tabel.
3. Buka salah satu tiket yang ingin dianalisis (misal: Tiket #9658).
4. Klik tombol **Analisis Bottleneck (Excel)** di pojok kanan atas formulir tiket.
5. Pilih tipe laporan: *Analisis Detail Bottleneck Tiket Tunggal*.
6. Klik **Unduh Laporan Excel**. File `.xlsx` 3 sheet akan otomatis diunduh ke komputer Anda.
---
## 4. Struktur Folder Modul
```text
gmn_module_v2/
├── README.md # Dokumentasi utama proyek ini
├── CHANGELOG.md # Catatan rilis dan riwayat perubahan
├── docs/ # Folder seluruh dokumen pendukung
│ ├── 00-INDEX.md # Indeks dokumen dan panduan membaca
│ ├── 01-bisnis/ # BRD (Business Requirements Document)
│ ├── 02-kebutuhan/ # SRS & Katalog Aturan Bisnis
│ ├── 03-desain/ # FDD, TDD, Arsitektur, Kamus Data, ADR
│ ├── 04-pengujian/ # Test Plan, Test Case, Laporan Validasi, UAT
│ ├── 05-operasional/ # Panduan Pengguna, SOP, Runbook, Konfigurasi
│ └── 06-manajemen/ # Risiko, Change Request, Traceability Matrix, Glosarium
└── gmn_freshservice/ # Kode sumber addon Odoo 19
├── __init__.py
├── __manifest__.py
├── models/ # Model Odoo: ticket, stage_log, conversation, settings
├── services/ # Client integrasi Freshservice REST API v2
├── wizard/ # Wizard export laporan Excel via xlsxwriter
├── views/ # XML view Odoo (form, list, settings, menu)
├── data/ # Demo data & konfigurasi default
├── security/ # Hak akses ir.model.access.csv
└── tests/ # Unit test otomatis & validasi acceptance
```
---
## 5. Catatan Penulisan & Verifikasi Integritas (Self-Check)
1. **Daftar File Kode Sumber yang Dianalisis**:
- `gmn_freshservice/__manifest__.py`
- `gmn_freshservice/models/gmn_freshservice_ticket_v01.py`
- `gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py`
- `gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py`
- `gmn_freshservice/models/gmn_res_config_settings_v01.py`
- `gmn_freshservice/services/gmn_freshservice_client_v01.py`
- `gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py`
- `gmn_freshservice/tests/test_responsibility_rules.py`
2. **Daftar Item [PERLU KONFIRMASI]**:
- PK-01: Nama resmi departemen pemohon SLS apakah ada kepanjangan divisi spesifik di luar data Freshservice? (Asumsi saat ini: SLS merepresentasikan departemen operasional pemohon).
- PK-02: Jadwal rilis resmi ke server staging user apakah mengikuti jadwal sprint dua mingguan tim IT?
- PK-03: Kebijakan retensi data teks pesan percakapan di database Odoo lokal (apakah dipangkas setelah 1 tahun atau disimpan selamanya).
3. **Daftar Aturan yang Semula Direncanakan [PLANNED] dan Telah Berhasil [IMPLEMENTED] di Kode**:
- Aturan A (Sticky State & Event Status Eksplisit): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`.
- Aturan B (Ball-in-Court Analysis & Deteksi Ambiguitas): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::_ball_in_court_check`.
- Aturan C (User Setelah Auto-Resolve Sistem): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`.
- Aturan D (Deteksi Rapat Bersama & Pembatalan Zoom): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`.
- Aturan E (First Ack vs First Meaningful Response): Telah diimplementasikan di `gmn_freshservice_ticket_v01.py::action_sync_from_freshservice`.
- Aturan F (Override Manual & Cut-off as_of): Telah diimplementasikan di `gmn_freshservice_ticket_stage_log_v01.py` dan wizard `gmn_freshservice_ticket_bottleneck_wizard_v01.py`.
4. **Daftar Baris GAP pada Traceability Matrix**:
- GAP-01: Belum ada test otomatis khusus UI/JS web client saat tombol 'Tarik Tiket Terbaru' ditekan (ditutup via pengujian fungsional UAT manual).
5. **Konfirmasi Privasi**:
- Seluruh data contoh, transkrip pesan, dan cuplikan pengujian telah **dianonimkan 100%**. Tidak ada alamat email riil, nomor telepon, link rapat Zoom aktif, maupun passcode rapat yang disertakan dalam dokumentasi ini.

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
"""Modul utama GMN Freshservice.
Import semua sub-package: models, services, dan wizard.
"""
from . import models
from . import services
from . import wizard

View File

@@ -20,10 +20,13 @@ Modul untuk melacak audit trail tiket dan memisahkan durasi penanganan:
'mail',
],
'data': [
'security/gmn_freshservice_security_v01.xml',
'security/ir.model.access.csv',
'data/gmn_demo_data_v01.xml',
'views/gmn_res_config_settings_views_v01.xml',
'views/gmn_freshservice_ticket_stage_log_views_v01.xml',
'views/gmn_freshservice_holiday_views_v01.xml',
'wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml',
'wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml',
'views/gmn_freshservice_ticket_views_v01.xml',
'views/gmn_menu_views_v01.xml',

View File

@@ -1,360 +0,0 @@
# 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.

View File

@@ -1,5 +1,9 @@
# -*- coding: utf-8 -*-
"""Package models: semua model Odoo untuk modul Freshservice.
Urutan import penting karena ticket bergantung pada stage_log dan conversation.
"""
from . import gmn_res_config_settings_v01
from . import gmn_freshservice_holiday_v01
from . import gmn_freshservice_ticket_stage_log_v01
from . import gmn_freshservice_ticket_conversation_v01
from . import gmn_freshservice_ticket_v01

View File

@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""Model Master Data Hari Libur Nasional & Cuti Bersama.
Menyimpan daftar tanggal merah resmi Indonesia dan cuti bersama perusahaan
yang digunakan oleh Working Hours Engine untuk memotong jam kerja secara presisi.
Mendukung:
1. Input manual melalui antarmuka Tree/Form Odoo.
2. Impor massal dari file spreadsheet Excel (.xlsx).
3. Pengunduhan template spreadsheet Excel (.xlsx) dengan contoh data.
4. Pemuatan instan data baku hari libur nasional Indonesia (2024-2026).
"""
import io
import base64
import logging
from datetime import datetime, date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import xlsxwriter
_logger = logging.getLogger(__name__)
# Daftar Baku Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026)
DEFAULT_INDONESIA_HOLIDAYS = [
# 2024
('2024-01-01', 'Tahun Baru 2024 Masehi', 'public', 'SKB 3 Menteri 2024'),
('2024-02-08', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'),
('2024-02-09', 'Cuti Bersama Tahun Baru Imlek 2575 Kongzili', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-02-10', 'Tahun Baru Imlek 2575 Kongzili', 'public', 'SKB 3 Menteri 2024'),
('2024-03-11', 'Hari Suci Nyepi Tahun Baru Saka 1946', 'public', 'SKB 3 Menteri 2024'),
('2024-03-12', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-03-29', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2024'),
('2024-03-31', 'Hari Paskah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-08', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-09', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-10', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-11', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-12', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-15', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2024'),
('2024-05-09', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2024'),
('2024-05-10', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-05-23', 'Hari Raya Waisak 2568 BE', 'public', 'SKB 3 Menteri 2024'),
('2024-05-24', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2024'),
('2024-06-17', 'Hari Raya Idul Adha 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-06-18', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-07-07', 'Tahun Baru Islam 1446 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-08-17', 'Hari Proklamasi Kemerdekaan RI ke-79', 'public', 'SKB 3 Menteri 2024'),
('2024-09-16', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'),
('2024-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2024'),
('2024-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2024'),
# 2025
('2025-01-01', 'Tahun Baru 2025 Masehi', 'public', 'SKB 3 Menteri 2025'),
('2025-01-27', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'),
('2025-01-28', 'Cuti Bersama Tahun Baru Imlek 2576 Kongzili', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-01-29', 'Tahun Baru Imlek 2576 Kongzili', 'public', 'SKB 3 Menteri 2025'),
('2025-03-28', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-03-29', 'Hari Suci Nyepi Tahun Baru Saka 1947', 'public', 'SKB 3 Menteri 2025'),
('2025-03-31', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-04-01', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-04-02', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-03', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-04', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-07', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-18', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2025'),
('2025-04-20', 'Hari Paskah', 'public', 'SKB 3 Menteri 2025'),
('2025-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2025'),
('2025-05-12', 'Hari Raya Waisak 2569 BE', 'public', 'SKB 3 Menteri 2025'),
('2025-05-13', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-05-29', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2025'),
('2025-05-30', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2025'),
('2025-06-06', 'Hari Raya Idul Adha 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-06-09', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-06-27', '1 Muharam Tahun Baru Islam 1447 H', 'public', 'SKB 3 Menteri 2025'),
('2025-08-17', 'Hari Proklamasi Kemerdekaan RI ke-80', 'public', 'SKB 3 Menteri 2025'),
('2025-09-05', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'),
('2025-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2025'),
('2025-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2025'),
# 2026
('2026-01-01', 'Tahun Baru 2026 Masehi', 'public', 'SKB 3 Menteri 2026'),
('2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'),
('2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'public', 'SKB 3 Menteri 2026'),
('2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'public', 'SKB 3 Menteri 2026'),
('2026-04-03', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2026'),
('2026-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2026'),
('2026-05-14', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2026'),
('2026-05-31', 'Hari Raya Waisak 2570 BE', 'public', 'SKB 3 Menteri 2026'),
('2026-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2026'),
('2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'public', 'SKB 3 Menteri 2026'),
('2026-08-25', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'),
('2026-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2026'),
]
class FreshserviceHoliday(models.Model):
"""Model Master Data Hari Libur Nasional & Cuti Bersama.
Menyimpan tanggal merah yang dijadikan acuan pemotongan jam kerja oleh modul.
"""
_name = 'freshservice.holiday'
_description = 'Master Data Hari Libur & Cuti Bersama Indonesia'
_order = 'date desc, id desc'
name = fields.Char(string='Nama Hari Libur / Keterangan', required=True, index=True)
date = fields.Date(string='Tanggal Libur', required=True, index=True)
holiday_type = fields.Selection([
('public', 'Hari Libur Nasional'),
('joint_leave', 'Cuti Bersama'),
('company', 'Libur Khusus Perusahaan'),
], string='Jenis Libur', default='public', required=True)
year = fields.Integer(string='Tahun', compute='_compute_year', store=True, index=True)
active = fields.Boolean(string='Aktif', default=True)
notes = fields.Text(string='Catatan Tambahan')
_sql_constraints = [
('date_unique', 'unique(date)', 'Tanggal hari libur ini sudah terdaftar dalam sistem!')
]
@api.depends('date')
def _compute_year(self):
"""Menghitung tahun secara otomatis dari tanggal libur."""
for rec in self:
rec.year = rec.date.year if rec.date else 0
def action_populate_default_holidays(self, *args, **kwargs):
"""Memuat daftar hari libur baku Indonesia (2024-2026) ke dalam database.
Hanya menambahkan data yang belum terdaftar tanpa menimpa data yang sudah ada.
"""
created_count = 0
for date_str, name, h_type, notes in DEFAULT_INDONESIA_HOLIDAYS:
existing = self.search([('date', '=', date_str)], limit=1)
if not existing:
self.create({
'date': date_str,
'name': name,
'holiday_type': h_type,
'notes': notes,
'active': True,
})
created_count += 1
message = _('Berhasil memuat %d hari libur nasional baku ke database.') % created_count if created_count > 0 else _('Seluruh hari libur nasional baku sudah terdaftar.')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Pemuatan Data Baku Selesai'),
'message': message,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
}
}
def action_download_template(self, *args, **kwargs):
"""Membuat dan mengunduh file template Excel (.xlsx) untuk impor hari libur.
Template dilengkapi dengan format kolom yang benar dan contoh data resmi 2026.
"""
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
worksheet = workbook.add_worksheet('Template Hari Libur')
# Formatter Styling Excel
header_banner_fmt = workbook.add_format({
'bold': True, 'font_size': 14, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#1B365D',
'align': 'left', 'valign': 'vcenter'
})
info_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'font_color': '#5D6D7E',
'italic': True, 'valign': 'vcenter'
})
th_fmt = workbook.add_format({
'bold': True, 'font_size': 10, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#2E4053',
'align': 'center', 'valign': 'vcenter', 'border': 1, 'border_color': '#BDC3C7'
})
td_center_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_date_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter',
'num_format': 'yyyy-mm-dd'
})
td_left_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'left',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
# Set Lebar Kolom
worksheet.set_column('A:A', 6)
worksheet.set_column('B:B', 16)
worksheet.set_column('C:C', 40)
worksheet.set_column('D:D', 26)
worksheet.set_column('E:E', 35)
# Header Title Banner
worksheet.merge_range('A1:E1', 'TEMPLATE IMPORT HARI LIBUR NASIONAL & CUTI BERSAMA', header_banner_fmt)
worksheet.set_row(0, 30)
# Petunjuk Pengisian
worksheet.write('A2', 'PETUNJUK PENGISIAN:', info_fmt)
worksheet.write('A3', '1. Kolom Tanggal Libur (*) dan Nama Hari Libur (*) wajib diisi. Format tanggal: YYYY-MM-DD (contoh: 2026-08-17).', info_fmt)
worksheet.write('A4', '2. Kolom Jenis Libur dapat diisi: "Hari Libur Nasional", "Cuti Bersama", atau "Libur Khusus Perusahaan".', info_fmt)
worksheet.write('A5', '3. Anda dapat mengedit, menambah, atau menghapus baris contoh di bawah ini sebelum mengimpor.', info_fmt)
# Table Headers (Baris 7)
headers = ['No', 'Tanggal Libur (*)', 'Nama Hari Libur (*)', 'Jenis Libur', 'Keterangan']
for col_idx, h in enumerate(headers):
worksheet.write(6, col_idx, h, th_fmt)
worksheet.set_row(6, 24)
# Contoh Data (Hari Libur Resmi 2026 sebagai sampel lengkap)
sample_data = [
(1, '2026-01-01', 'Tahun Baru 2026 Masehi', 'Hari Libur Nasional', 'Libur Nasional Resmi'),
(2, '2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(3, '2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(4, '2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(5, '2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(6, '2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(7, '2026-04-03', 'Wafat Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(8, '2026-05-01', 'Hari Buruh Internasional', 'Hari Libur Nasional', 'Hari Buruh'),
(9, '2026-05-14', 'Kenaikan Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(10, '2026-05-31', 'Hari Raya Waisak 2570 BE', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(11, '2026-06-01', 'Hari Lahir Pancasila', 'Hari Libur Nasional', 'Hari Lahir Pancasila'),
(12, '2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(13, '2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(14, '2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'Hari Libur Nasional', 'Proklamasi Kemerdekaan'),
(15, '2026-08-25', 'Maulid Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(16, '2026-12-25', 'Hari Raya Natal', 'Hari Libur Nasional', 'Hari Natal'),
]
row = 7
for no, dt_str, name, h_type, notes in sample_data:
worksheet.write(row, 0, no, td_center_fmt)
worksheet.write(row, 1, dt_str, td_date_fmt)
worksheet.write(row, 2, name, td_left_fmt)
worksheet.write(row, 3, h_type, td_left_fmt)
worksheet.write(row, 4, notes, td_left_fmt)
row += 1
workbook.close()
output.seek(0)
file_data = output.read()
output.close()
filename = 'Template_Hari_Libur_Indonesia.xlsx'
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': base64.b64encode(file_data),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'public': True,
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'new',
}
def action_open_import_wizard(self, *args, **kwargs):
"""Membuka dialog popup wizard impor hari libur dari file Excel."""
return {
'name': _('Impor Hari Libur dari Excel'),
'type': 'ir.actions.act_window',
'res_model': 'freshservice.holiday.import.wizard',
'view_mode': 'form',
'target': 'new',
'context': dict(self.env.context),
}

View File

@@ -1,8 +1,14 @@
# -*- coding: utf-8 -*-
"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice.
Setiap baris adalah satu pesan, baik dari user maupun dari agen IT.
"""
from odoo import models, fields
class FreshserviceTicketConversation(models.Model):
_name = 'freshservice.ticket.conversation'
"""Menyimpan pesan/balasan pada tiket.
Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel.
"""
_description = 'Percakapan & Balasan Tiket Freshservice'
_order = 'created_at asc, id asc'
@@ -25,12 +31,16 @@ class FreshserviceTicketConversation(models.Model):
sender_type = fields.Selection([
('agent', 'IT Support (Agent)'),
('customer', 'User (Customer)'),
('customer', 'User (Requester)'),
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
body_text = fields.Text(string='Isi Pesan')
created_at = fields.Datetime(string='Waktu Kirim')
def _compute_sender_type(self):
"""Tentukan siapa yang kirim pesan:
- 'customer' jika incoming=True (pesan dari user)
- 'agent' jika incoming=False (balasan dari agen IT)
"""
for rec in self:
rec.sender_type = 'customer' if rec.incoming else 'agent'

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi.
Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan.
"""
from datetime import timedelta, datetime, time
from odoo import models, fields, api
@@ -70,20 +73,51 @@ _INDONESIA_HOLIDAYS = {
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 public leave dates combining Indonesian public holidays with Odoo's calendar leaves.
Prioritas Pengambilan Data:
1. Model Master Data `freshservice.holiday` di database (hasil input manual atau impor Excel).
2. Fallback ke konstanta `_INDONESIA_HOLIDAYS` jika tabel master data di DB masih kosong
atau jika env Odoo tidak tersedia (misal unit test standalone).
3. Menggabungkan dengan cuti dinamis dari `resource.calendar.leaves` jika tersedia.
"""
res = set()
used_db = False
if env and hasattr(env, '__getitem__'):
try:
HolidayModel = env['freshservice.holiday'].sudo()
# Cek apakah ada record hari libur aktif di database
db_records = HolidayModel.search([
('date', '>=', start_date),
('date', '<=', end_date),
('active', '=', True),
])
if db_records:
res.update(rec.date for rec in db_records)
used_db = True
elif HolidayModel.search_count([('active', '=', True)]) > 0:
# Database sudah memiliki data libur, namun tidak ada libur di rentang tanggal ini
used_db = True
except Exception:
pass
if not used_db:
# Fallback ke konstanta _INDONESIA_HOLIDAYS jika DB belum terisi / tanpa koneksi DB
res.update(d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date)
if env and hasattr(env, '__getitem__'):
try:
leaves = env['resource.calendar.leaves'].sudo().search([
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
('date_to', '>=', datetime.combine(start_date, time.min)),
])
for leave in leaves:
if leave.date_from:
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
except Exception:
pass
return res
@@ -240,6 +274,11 @@ def format_duration_human(hours_float):
return f"{days} Hari"
class FreshserviceTicketStageLog(models.Model):
"""Satu baris = satu fase/status di tiket.
Contoh: Open → In Progress → Pending User → Resolved.
Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya,
dan siapa yang bertanggung jawab (Agent/User/Third Party).
"""
_name = 'freshservice.ticket.stage.log'
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
_order = 'start_date asc, id asc'
@@ -255,15 +294,36 @@ class FreshserviceTicketStageLog(models.Model):
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)
('internal', 'Agent Active Work'),
('it_hold', 'Agent Hold & Discussion'),
('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('needs_verification', 'Waiting on User Verification'),
('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('third_party', 'Waiting on Third Party'),
('resolved_idle', 'Resolved / Idle'),
# Legacy values are retained for historical audit snapshots.
('on_hold', 'On Hold (Legacy)'),
('internal_discussion', 'Internal Discussion (Legacy)'),
('external_activity', 'External Activity (Legacy)'),
], string='Task Owner', required=True, default='internal', index=True)
effective_status = fields.Char(string='Effective Status', index=True)
classification_basis = fields.Char(string='Classification Basis')
needs_verification = fields.Boolean(string='User Verification Required')
delay_cause = fields.Selection([
('it', 'Agent'), ('user', 'User'), ('none', 'None'),
], string='Delay Cause', default='none')
automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True)
override_responsibility = fields.Selection([
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'),
], string='Task Owner Override')
override_source = fields.Char(string='Override Source')
override_reviewer = fields.Char(string='Override Reviewer')
override_at = fields.Datetime(string='Override Date')
actor_name = fields.Char(string='Pelaku / Actor')
actor_type = fields.Selection([
@@ -324,11 +384,15 @@ class FreshserviceTicketStageLog(models.Model):
@api.depends('end_date')
def _compute_is_active(self):
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
for rec in self:
rec.is_active = not bool(rec.end_date)
@api.depends('start_date', 'end_date', 'is_active')
def _compute_working_metrics(self):
"""Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat)
dan konversi ke hari kerja (1 hari = 8 jam).
"""
for rec in self:
if rec.start_date:
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
@@ -342,6 +406,9 @@ class FreshserviceTicketStageLog(models.Model):
@api.depends('start_date', 'end_date')
def _compute_duration(self):
"""Hitung durasi dalam hari kalender dan jam total.
Kalau stage masih aktif, hitung sampai waktu sekarang.
"""
for rec in self:
if rec.start_date:
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
"""Model utama Freshservice Ticket.
Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API.
"""
import re
import logging
from datetime import datetime, timedelta, timezone
@@ -10,6 +13,11 @@ from .gmn_freshservice_ticket_stage_log_v01 import format_duration_human, format
_logger = logging.getLogger(__name__)
class FreshserviceTicket(models.Model):
"""Model tiket Freshservice di Odoo.
Menyimpan info tiket (judul, status, prioritas, pemohon, agen),
metrik SLA (first response, resolution time, breakdown per tanggung jawab),
dan relasi ke stage log (audit trail) serta percakapan.
"""
_name = 'freshservice.ticket'
_description = 'Freshservice Ticket with Audit Trail'
_inherit = ['mail.thread', 'mail.activity.mixin']
@@ -36,7 +44,7 @@ class FreshserviceTicket(models.Model):
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)
responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True)
department_name = fields.Char(string='Departemen')
# ==========================================
@@ -62,9 +70,13 @@ class FreshserviceTicket(models.Model):
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
due_by = fields.Datetime(string='Batas Waktu (Due Date)')
first_responded_at = fields.Datetime(string='Waktu First Response')
first_ack_at = fields.Datetime(string='Waktu First Acknowledgement', readonly=True)
first_meaningful_response_at = fields.Datetime(
string='Waktu First Meaningful Response', readonly=True,
)
resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)')
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, readonly=True)
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True)
is_detail_synced = fields.Boolean(
string='Detail Lengkap Tersinkron',
default=False,
@@ -112,24 +124,58 @@ class FreshserviceTicket(models.Model):
digits=(12, 2)
)
total_agent_time_display = fields.Char(
string='Agen (Waiting on Us)',
string='Agent Responsibility',
compute='_compute_time_metrics',
store=True
)
total_customer_time_days = fields.Integer(
string='Total Waktu di User (Hari)',
string='Requester Time (Days)',
compute='_compute_time_metrics',
store=True
)
total_customer_time_hours = fields.Float(
string='Total Waktu di User (Jam)',
string='Requester Time (Hours)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
total_customer_time_display = fields.Char(
string='User (Waiting on User)',
string='Requester Responsibility',
compute='_compute_time_metrics',
store=True
)
shared_wait_time_days = fields.Integer(
string='Scheduled Meeting (Days)',
compute='_compute_time_metrics',
store=True
)
shared_wait_time_hours = fields.Float(
string='Scheduled Meeting (Hours)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
shared_wait_time_display = fields.Char(
string='Scheduled Meeting',
compute='_compute_time_metrics',
store=True
)
third_party_time_days = fields.Integer(
string='Waiting on Third Party (Hari)',
compute='_compute_time_metrics',
store=True
)
third_party_time_hours = fields.Float(
string='Waiting on Third Party (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
third_party_time_display = fields.Char(
string='Waiting on Third Party',
compute='_compute_time_metrics',
store=True
)
@@ -146,16 +192,16 @@ class FreshserviceTicket(models.Model):
digits=(12, 2)
)
resolution_time_display = fields.Char(
string='Total Waktu Selesai',
string='Total Resolution Time',
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)
('internal', 'Agent Active Work'),
('customer', 'Waiting on Requester'),
('closed', 'Completed'),
], string='Current Responsibility', compute='_compute_time_metrics', store=True)
# Relasi
stage_log_ids = fields.One2many(
@@ -171,9 +217,15 @@ class FreshserviceTicket(models.Model):
@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'
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active'
)
def _compute_time_metrics(self):
"""Hitung semua metrik waktu tiket:
- First Response Time (berapa lama sampai agen pertama kali merespon)
- Breakdown jam kerja per tanggung jawab (Agent/User/Meeting/Third Party)
- Resolution Time (total waktu sampai tiket selesai)
- Status tanggung jawab saat ini (siapa yang harus action)
"""
for rec in self:
# 1. Hitung First Response
if rec.ticket_created_at and rec.first_responded_at:
@@ -190,14 +242,24 @@ class FreshserviceTicket(models.Model):
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')
# Mengikuti Aturan Fair Responsibility:
# - IT Aktif / IT Hold / On Hold / Internal Discussion: Agen (Waiting on Us)
# - User / User Setelah Auto Resolve: Customer (Waiting on User)
# - Perlu Verifikasi: Dicatat tersendiri / dapat masuk rentang estimasi
# - Scheduled Meeting: Bersama
# - Third Party: Pihak Ketiga
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('internal', 'it_hold', 'on_hold', 'internal_discussion'))
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('customer', 'user_after_auto_resolve', 'needs_verification'))
shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'scheduled_meeting')
third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('third_party', 'external_activity'))
agent_days = round(agent_hours / 8.0, 2)
cust_days = round(cust_hours / 8.0, 2)
shared_wait_days = round(shared_wait_hours / 8.0, 2)
third_party_days = round(third_party_hours / 8.0, 2)
# Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT.
total_working_hours = sum(l.working_hours for l in rec.stage_log_ids)
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'resolved_idle')
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'resolved_idle')
rec.total_agent_time_hours = round(agent_hours, 2)
rec.total_agent_time_days = round(agent_days)
@@ -207,6 +269,14 @@ class FreshserviceTicket(models.Model):
rec.total_customer_time_days = round(cust_days)
rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
rec.shared_wait_time_hours = round(shared_wait_hours, 2)
rec.shared_wait_time_days = round(shared_wait_days)
rec.shared_wait_time_display = format_duration_days(round(shared_wait_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
rec.third_party_time_hours = round(third_party_hours, 2)
rec.third_party_time_days = round(third_party_days)
rec.third_party_time_display = format_duration_days(round(third_party_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
# 3. Hitung Resolution Time
if rec.ticket_created_at and rec.resolved_at:
delta_res = rec.resolved_at - rec.ticket_created_at
@@ -246,7 +316,9 @@ class FreshserviceTicket(models.Model):
}
def action_assign_to_me(self):
"""Menugaskan tiket ini ke user Odoo yang sedang login"""
"""Tombol 'Assign ke Saya': tugaskan tiket ini ke user yang sedang login.
Otomatis catat di chatter siapa yang ambil alih tiket.
"""
self.ensure_one()
current_user = self.env.user
vals = {'user_id': current_user.id}
@@ -269,11 +341,35 @@ class FreshserviceTicket(models.Model):
}
}
def action_sync_from_freshservice(self):
def _ball_in_court_check(self, effective_status, last_msg, last_actor_type):
"""
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.
Aturan B: Ball-in-court analysis.
Mengevaluasi apakah stage hold/waiting memiliki potensi misklasifikasi
karena Agent sedang menunggu konfirmasi user atau sebaliknya.
Mengembalikan (is_needs_verification, basis_snippet).
"""
if not last_msg:
return False, ""
msg_lower = last_msg.lower()
confirmation_keywords = [
'mohon konfirmasi', 'konfirmasi', 'mohon dicek', 'silakan dicek',
'apakah sudah', 'apakah bisa', 'menunggu konfirmasi', 'apakah kendala',
'mohon info', 'tolong konfirmasi', 'konfirmasinya'
]
has_req = any(kw in msg_lower for kw in confirmation_keywords) or ('?' in last_msg and last_actor_type == 'Agent')
if has_req and (effective_status in ('Waiting on Internal Discussion', 'On Hold') or 'hold' in str(effective_status).lower()):
words = last_msg.strip().split()
snippet = ' '.join(words[:10]) + ('...' if len(words) > 10 else '')
return True, f"Agent meminta konfirmasi: \"{snippet}\""
return False, ""
def action_sync_from_freshservice(self):
"""Tarik data lengkap tiket dari Freshservice API.
Proses:
1. Ambil detail tiket (subject, status, requester, dll)
2. Ambil audit trail activities lalu rekonstruksi jadi stage log
3. Ambil percakapan (reply/note)
Pakai row-lock supaya aman kalau 2 user sync bersamaan.
"""
self.ensure_one()
@@ -289,9 +385,9 @@ class FreshserviceTicket(models.Model):
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)
base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
api_key = get_param('gmn_freshservice.freshservice_api_key', '')
use_mock = False
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
payload = client.fetch_ticket_full_data(self.external_ticket_id)
@@ -389,7 +485,13 @@ class FreshserviceTicket(models.Model):
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
log_vals_list = []
# Hanya perubahan status eksplisit yang mengubah state berikutnya.
effective_status = 'Open'
current_state = 'internal'
last_public_message = False
last_message_actor_type = False
first_ack_dt = False
first_meaningful_dt = False
meeting_wait_until = False
meeting_start_dt = False
meeting_waiting_for_followup = False
@@ -504,59 +606,128 @@ class FreshserviceTicket(models.Model):
else:
stage_name = 'Aktivitas'
# 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine.
# Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state.
# 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification
# ==============================================================================
# Aturan A: Hanya event "set Status as X" yang mengubah effective_status.
# Event non-status (Timesheet, Note, Assignment, Reply) MEWARISI status sebelumnya.
# Aturan B: Ball-in-court check untuk IT_HOLD / Waiting on End User.
# Deteksi 2 segmen PERLU_VERIFIKASI (Sep 18 09:38-Sep 21 14:05 & Oct 6 09:28-Oct 9 10:44).
# Aturan C: Resolved oleh sistem/auto-rule → USER_SETELAH_AUTO_RESOLVE.
# Aturan D: Scheduled Meeting → BERSAMA dengan penanda delay_cause.
# Aturan E: First response substantif terpisah dari auto-ack.
# ==============================================================================
combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower()
is_timesheet = any(k in combined_text for k in ('timesheet', 'time spent', 'create timesheet'))
status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower()
explicit_status = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I))
vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text))
is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I))
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')):
# Update pesan terakhir jika ada balasan/catatan publik
if matched_msg:
last_public_message = matched_msg
last_message_actor_type = actor_type
elif 'replied' in content_main.lower() or 'reply' in content_main.lower():
last_public_message = content_main
last_message_actor_type = actor_type
# Rekam First Response (Aturan E)
if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()):
is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting'))
if not first_ack_dt:
first_ack_dt = start_dt_obj
if not is_auto_ack and not first_meaningful_dt:
first_meaningful_dt = start_dt_obj
classification_basis = "Status warisan: %s" % effective_status
needs_verification_flag = False
delay_cause = 'none'
# Update effective_status hanya jika ada perubahan eksplisit
if vendor_external or (explicit_status and 'waiting on third party' in status_text):
effective_status = 'Waiting on Third Party'
current_state = 'third_party'
classification_basis = 'Status Freshservice: Waiting on Third Party'
elif explicit_status and 'waiting on internal discussion' in status_text:
effective_status = 'Waiting on Internal Discussion'
current_state = 'it_hold'
classification_basis = 'Status Freshservice: Waiting on Internal Discussion'
elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')):
effective_status = 'Waiting on End User'
current_state = 'customer'
classification_basis = 'Status Freshservice: Waiting on End User'
elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')):
effective_status = 'On Hold'
current_state = 'it_hold'
classification_basis = 'Status Freshservice: On Hold'
elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')):
is_auto_resolve = actor_type == 'System' or 'auto-resolve' in combined_text or 'workflow' in combined_text
effective_status = 'Resolved'
if is_auto_resolve:
current_state = 'user_after_auto_resolve'
classification_basis = 'Auto-resolve oleh sistem (menunggu respon user)'
else:
current_state = 'resolved_idle'
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'
classification_basis = 'Status Freshservice: Resolved'
elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')):
effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open'
current_state = 'internal'
classification_basis = 'Status Freshservice: %s' % effective_status
responsibility = current_state
responsibility = current_state
# 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent.
# Aturan B: status hold tidak otomatis berarti IT menunggu internal.
# Bila pesan publik terakhir dari Agent meminta konfirmasi user, klasifikasi
# dibuat eksplisit sebagai perlu verifikasi dan menyimpan kutipan pemicunya.
is_ball_req, basis_ball = self._ball_in_court_check(
effective_status, last_public_message, last_message_actor_type,
)
if is_ball_req:
responsibility = 'needs_verification'
needs_verification_flag = True
classification_basis = basis_ball
# Penanganan khusus Create Timesheet: MEWARISI status efektif (Aturan A)
if 'timesheet' in combined_text:
stage_name = 'Create Timesheet'
# Timesheet tidak mengubah responsibility, tetap memakai responsibility dari status efektif
classification_basis = 'Timesheet (mewarisi status efektif %s)' % effective_status
# Penanganan balasan Agent: tidak membalik tanggung jawab jika status masih di user (Aturan A)
if actor_type == 'Agent' and any(k in content_main.lower() for k in ('replied', 'reply')) and current_state == 'customer':
responsibility = 'customer'
classification_basis = 'Balasan Agent (mewarisi status Waiting on End User)'
# 4a. Smart Detection: Undangan Zoom / Meeting & Aturan Pembatalan Meeting (Cancel Meeting)
is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or ''))
is_meeting_cancelled = False
# Cek apakah setelah link meeting dikirim, meeting dibatalkan atau digantikan oleh status resmi:
# 1. Perubahan status resmi selain "Waiting on Internal Discussion" (misal: In Progress, Waiting on End User, On Hold, Resolved).
# 2. Atau terdapat kata kunci pembatalan eksplisit (cancel/batal meeting/zoom/call).
if meeting_waiting_for_followup and not is_meeting_invitation:
has_explicit_status_change = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) or vendor_external or is_agent_assignment
is_internal_disc = 'waiting on internal discussion' in status_text.lower()
cancel_kw = bool(re.search(r'\b(batal|cancel|reschedule|tidak\s+jadi)\b.{0,30}\b(meeting|zoom|call|gmeet)\b', full_activity_content or '', re.I))
if has_explicit_status_change and not is_internal_disc:
# An explicit status transition ends the shared meeting phase,
# but it is not a cancellation by itself. The next status or
# activity is allowed to determine the normal responsibility.
meeting_waiting_for_followup = False
meeting_start_dt = False
meeting_wait_until = False
interval_meeting_start = False
if cancel_kw:
# Only explicit cancellation wording marks the phase as
# cancelled in the audit trail.
is_meeting_cancelled = True
stage_name = f'Meeting Cancelled / {stage_name}'
meeting_waiting_for_followup = False
meeting_start_dt = False
meeting_wait_until = False
interval_meeting_start = False
if is_meeting_invitation:
parsed_meeting_dt = False
time_match = meeting_time_pattern.search(full_activity_content or '')
@@ -571,6 +742,10 @@ class FreshserviceTicket(models.Model):
meeting_start_dt = parsed_meeting_dt
meeting_waiting_for_followup = True
meeting_wait_until = False
# Aktivitas pengiriman link dibebankan kepada pengirimnya.
# Jika Agent yang mengirim, stage log ini berwarna Internal (merah)
# di Excel. Fase Waiting Together baru dimulai setelah aktivitas ini.
responsibility = 'internal' if actor_type == 'Agent' else current_state
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
meeting_wait_until = False
@@ -581,22 +756,27 @@ class FreshserviceTicket(models.Model):
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
)
if is_meeting_invitation:
responsibility = 'scheduled_meeting'
# Pengiriman link adalah pekerjaan pembuat link. Jangan mengubahnya
# menjadi Waiting Together; status meeting dipakai aktivitas berikutnya.
responsibility = 'internal' if actor_type == 'Agent' else current_state
meeting_wait_until = False
elif 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
elif is_meeting_cancelled:
# Tanggung jawab kembali ke status operasional saat ini (current_state)
responsibility = current_state
elif (
meeting_waiting_for_followup
and not is_meeting_invitation
and 'waiting on internal discussion' in status_text.lower()
):
# Setelah link meeting dikirim, status ini masih merupakan
# fase Scheduled Meeting dan tidak membatalkan state meeting.
responsibility = 'scheduled_meeting'
elif meeting_waiting_for_followup:
# Keep the complete interval shared while the ticket remains in
# Internal Discussion or Scheduled Meeting. Do not split at the
# scheduled meeting start; the next explicit status/activity
# determines the new responsibility.
responsibility = 'scheduled_meeting'
# 5. Set First Responder jika belum terdeteksi
if not self.first_responder_name and actor_type == 'Agent':
@@ -606,35 +786,34 @@ class FreshserviceTicket(models.Model):
log_values = {
'stage_name': stage_name,
'responsibility': responsibility,
'effective_status': effective_status,
'classification_basis': classification_basis,
'needs_verification': needs_verification_flag,
'delay_cause': delay_cause,
'actor_name': actor_name,
'actor_type': actor_type,
'activity_content': full_activity_content or stage_name,
'start_date': start_dt,
'end_date': end_dt,
}
# 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))
# Keep one continuous shared-time interval while the effective status is
# Internal Discussion or Scheduled Meeting. The interval changes only
# when the next activity/status explicitly changes responsibility.
log_vals_list.append((0, 0, log_values))
ticket_write_vals = {}
if first_ack_dt:
ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S')
if first_meaningful_dt:
ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S')
if log_vals_list:
# Baru ganti snapshot setelah daftar audit trail pengganti siap.
self.stage_log_ids.unlink()
self.write({'stage_log_ids': log_vals_list})
ticket_write_vals['stage_log_ids'] = log_vals_list
self.write(ticket_write_vals)
elif ticket_write_vals:
self.write(ticket_write_vals)
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
conv_vals_list = []
@@ -657,7 +836,7 @@ class FreshserviceTicket(models.Model):
'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
)
mode_text = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "LIVE FRESHSERVICE API"
mode_text = "LIVE FRESHSERVICE API"
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
@@ -669,25 +848,6 @@ class FreshserviceTicket(models.Model):
}
}
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
# =========================================================================
@@ -711,14 +871,14 @@ class FreshserviceTicket(models.Model):
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)
limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
except (ValueError, TypeError):
limit = 200
if limit <= 0:
limit = 200
now_ts = fields.Datetime.now().timestamp()
last_sync = param_obj.get_param('freshservice_ticket_audit.last_list_sync_time')
last_sync = param_obj.get_param('gmn_freshservice.last_list_sync_time')
if last_sync:
try:
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
@@ -726,14 +886,14 @@ class FreshserviceTicket(models.Model):
return 0
except Exception:
pass
param_obj.set_param('freshservice_ticket_audit.last_list_sync_time', str(now_ts))
param_obj.set_param('gmn_freshservice.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)
base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '')
use_mock = False
try:
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=False)
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
except Exception as e:
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
@@ -817,7 +977,9 @@ class FreshserviceTicket(models.Model):
@api.model
def _extract_ticket_ids_from_domain(self, domain):
"""Ambil angka ticket ID dari domain pencarian agar bisa fetch on-demand."""
"""Cari angka-angka ticket ID dari domain pencarian Odoo.
Contoh: domain [('external_ticket_id', '=', '992')] → return ['992']
"""
ticket_ids = []
if not domain:
return ticket_ids
@@ -840,7 +1002,9 @@ class FreshserviceTicket(models.Model):
@api.model
def _fetch_and_create_ticket_by_external_id(self, ticket_id):
"""Ambil tiket tertentu dari Freshservice jika belum ada di Odoo."""
"""Cari tiket di Odoo berdasarkan ID Freshservice.
Kalau belum ada, buat record baru lalu langsung sync dari API.
"""
if ticket_id in (None, ''):
return self.browse()
@@ -874,20 +1038,18 @@ class FreshserviceTicket(models.Model):
@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.
"""Dipanggil saat user klik menu 'Daftar Tiket'.
Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list.
"""
try:
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
except Exception as e:
_logger.warning("Auto fetch saat buka menu tiket: %s", e)
action = self.env['ir.actions.act_window']._for_xml_id('freshservice_ticket_audit.action_freshservice_ticket')
action = self.env['ir.actions.act_window']._for_xml_id('gmn_freshservice.action_freshservice_ticket')
param_obj = self.env['ir.config_parameter'].sudo()
try:
sync_limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200)
sync_limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
if sync_limit > 0:
action['limit'] = sync_limit
except Exception:
@@ -895,19 +1057,24 @@ class FreshserviceTicket(models.Model):
return action
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=100):
def name_search(self, name='', domain=None, operator='ilike', limit=100, **kwargs):
"""Override name_search: kalau user ketik nomor tiket yang belum ada di Odoo,
otomatis fetch dari Freshservice dulu baru tampilkan.
"""
if domain is None and 'args' in kwargs:
domain = kwargs.pop('args')
if name and isinstance(name, str):
ticket_ids = re.findall(r'\d+', name.strip())
for ticket_id in ticket_ids:
if not self.search_count([('external_ticket_id', '=', ticket_id)]):
self._fetch_and_create_ticket_by_external_id(ticket_id)
return super().name_search(name=name, args=args, operator=operator, limit=limit)
return super().name_search(name=name, domain=domain, operator=operator, limit=limit, **kwargs)
@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
"""
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket.
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
"""Override web_search_read: otomatis sync tiket terbaru dari Freshservice
setiap kali user buka atau refresh halaman list tiket.
Juga cek kalau ada nomor tiket di pencarian yang belum ada di Odoo.
"""
if offset == 0 and not self.env.context.get('skip_auto_fetch'):
try:
@@ -925,9 +1092,8 @@ class FreshserviceTicket(models.Model):
@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.
"""Override web_read: saat user buka detail tiket (form view),
kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice.
"""
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'):
# Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
@@ -948,8 +1114,8 @@ class FreshserviceTicket(models.Model):
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.
"""Tombol manual 'Tarik Tiket Terbaru' di halaman list.
Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice.
"""
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
return {

View File

@@ -1,35 +1,45 @@
# -*- coding: utf-8 -*-
"""Konfigurasi Settings untuk koneksi Freshservice.
User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo.
"""
from odoo import models, fields, api
from odoo.exceptions import UserError
import requests
class ResConfigSettings(models.TransientModel):
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
Bisa diakses dari menu Settings > Freshservice.
"""
_inherit = 'res.config.settings'
freshservice_url = fields.Char(
string='Freshservice URL',
config_parameter='freshservice_ticket_audit.freshservice_url',
config_parameter='gmn_freshservice.freshservice_url',
default='https://ptglobalmultimedianetwork.freshservice.com'
)
freshservice_api_key = fields.Char(
string='Freshservice API Key',
config_parameter='freshservice_ticket_audit.freshservice_api_key'
config_parameter='gmn_freshservice.freshservice_api_key'
)
freshservice_sync_limit = fields.Integer(
string='Limit Sinkronisasi Tiket',
config_parameter='freshservice_ticket_audit.freshservice_sync_limit',
config_parameter='gmn_freshservice.freshservice_sync_limit',
default=200,
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
)
freshservice_use_mock = fields.Boolean(
string='Gunakan Mock Data Engine',
config_parameter='freshservice_ticket_audit.freshservice_use_mock',
# default=True,
help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.'
config_parameter='gmn_freshservice.freshservice_use_mock',
default=False,
readonly=True,
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
)
def action_test_freshservice_connection(self):
"""Uji konektivitas ke server Freshservice"""
"""Tombol 'Test Connection' di halaman Settings.
Coba panggil API Freshservice untuk cek apakah URL dan API Key valid.
"""
self.ensure_one()
url = (self.freshservice_url or "").rstrip("/")
if not url:

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================
MODULE CATEGORY: Freshservice
Di Odoo 19, ir.module.category menjadi section header tersendiri
di tab Access Rights form User (seperti SALES, SERVICES, dll).
============================================================ -->
<record id="module_category_freshservice" model="ir.module.category">
<field name="name">Freshservice</field>
<field name="description">Modul Analisis Bottleneck &amp; Audit Trail Tiket Freshservice</field>
<field name="sequence">25</field>
</record>
<!-- ============================================================
PRIVILEGE: Freshservice (WAJIB DI ODOO 19)
Di Odoo 19, form User TIDAK LAGI membaca category_id di res.groups.
Odoo 19 mewajibkan model res.groups.privilege.
Category HANYA akan dirender jika memiliki privilege_ids
yang terhubung ke res.groups via privilege_id.
============================================================ -->
<record id="res_groups_privilege_freshservice" model="res.groups.privilege">
<field name="name">Role</field>
<field name="sequence">10</field>
<field name="category_id" ref="module_category_freshservice"/>
</record>
<!-- ============================================================
GROUP 1: Viewer / Agent IT (Read-Only)
- Menggunakan privilege_id (bukan category_id) agar muncul di Odoo 19
- sequence 10: tampil pertama di dropdown
============================================================ -->
<record id="group_freshservice_user" model="res.groups">
<field name="name">Viewer / Agent IT</field>
<field name="sequence">10</field>
<field name="privilege_id" ref="res_groups_privilege_freshservice"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Hak baca (Read) pada seluruh tiket, audit trail, percakapan, dan hari libur. Dapat mengakses wizard bottleneck report untuk generate laporan Excel.</field>
</record>
<!-- ============================================================
GROUP 2: Supervisor / IT Manager (Full CRUD)
- Menggunakan privilege_id (bukan category_id) agar muncul di Odoo 19
- sequence 20: tampil kedua di dropdown
- implied_ids mewarisi group_freshservice_user
- user_ids otomatis menambahkan administrator
============================================================ -->
<record id="group_freshservice_manager" model="res.groups">
<field name="name">Supervisor / IT Manager</field>
<field name="sequence">20</field>
<field name="privilege_id" ref="res_groups_privilege_freshservice"/>
<field name="implied_ids" eval="[(4, ref('group_freshservice_user'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
<field name="comment">Hak penuh (CRUD) serta hak mengeksekusi sinkronisasi API, manual override, impor hari libur, dan ekspor laporan bottleneck.</field>
</record>
</odoo>

View File

@@ -1,5 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_freshservice_ticket_user,freshservice.ticket.user,model_freshservice_ticket,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
access_freshservice_ticket_user,freshservice.ticket.user,model_freshservice_ticket,gmn_freshservice.group_freshservice_user,1,0,0,0
access_freshservice_ticket_manager,freshservice.ticket.manager,model_freshservice_ticket,gmn_freshservice.group_freshservice_manager,1,1,1,1
access_freshservice_ticket_stage_log_user,freshservice.ticket.stage.log.user,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_user,1,0,0,0
access_freshservice_ticket_stage_log_manager,freshservice.ticket.stage.log.manager,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_manager,1,1,1,1
access_freshservice_ticket_conversation_user,freshservice.ticket.conversation.user,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_user,1,1,1,1
access_freshservice_ticket_conversation_manager,freshservice.ticket.conversation.manager,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_manager,1,1,1,1
access_freshservice_holiday_user,freshservice.holiday.user,model_freshservice_holiday,gmn_freshservice.group_freshservice_user,1,0,0,0
access_freshservice_holiday_manager,freshservice.holiday.manager,model_freshservice_holiday,gmn_freshservice.group_freshservice_manager,1,1,1,1
access_freshservice_holiday_import_wizard_manager,freshservice.holiday.import.wizard.manager,model_freshservice_holiday_import_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1
access_freshservice_ticket_bottleneck_wizard_user,freshservice.ticket.bottleneck.wizard.user,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_user,1,1,1,1
access_freshservice_ticket_bottleneck_wizard_manager,freshservice.ticket.bottleneck.wizard.manager,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_freshservice_ticket_user freshservice.ticket.user model_freshservice_ticket base.group_user gmn_freshservice.group_freshservice_user 1 1 0 1 0 1 0
3 access_freshservice_ticket_stage_log_user access_freshservice_ticket_manager freshservice.ticket.stage.log.user freshservice.ticket.manager model_freshservice_ticket_stage_log model_freshservice_ticket base.group_user gmn_freshservice.group_freshservice_manager 1 1 1 1
4 access_freshservice_ticket_conversation_user access_freshservice_ticket_stage_log_user freshservice.ticket.conversation.user freshservice.ticket.stage.log.user model_freshservice_ticket_conversation model_freshservice_ticket_stage_log base.group_user gmn_freshservice.group_freshservice_user 1 1 0 1 0 1 0
5 access_freshservice_ticket_bottleneck_wizard_user access_freshservice_ticket_stage_log_manager freshservice.ticket.bottleneck.wizard.user freshservice.ticket.stage.log.manager model_freshservice_ticket_bottleneck_wizard model_freshservice_ticket_stage_log base.group_user gmn_freshservice.group_freshservice_manager 1 1 1 1
6 access_freshservice_ticket_conversation_user freshservice.ticket.conversation.user model_freshservice_ticket_conversation gmn_freshservice.group_freshservice_user 1 1 1 1
7 access_freshservice_ticket_conversation_manager freshservice.ticket.conversation.manager model_freshservice_ticket_conversation gmn_freshservice.group_freshservice_manager 1 1 1 1
8 access_freshservice_holiday_user freshservice.holiday.user model_freshservice_holiday gmn_freshservice.group_freshservice_user 1 0 0 0
9 access_freshservice_holiday_manager freshservice.holiday.manager model_freshservice_holiday gmn_freshservice.group_freshservice_manager 1 1 1 1
10 access_freshservice_holiday_import_wizard_manager freshservice.holiday.import.wizard.manager model_freshservice_holiday_import_wizard gmn_freshservice.group_freshservice_manager 1 1 1 1
11 access_freshservice_ticket_bottleneck_wizard_user freshservice.ticket.bottleneck.wizard.user model_freshservice_ticket_bottleneck_wizard gmn_freshservice.group_freshservice_user 1 1 1 1
12 access_freshservice_ticket_bottleneck_wizard_manager freshservice.ticket.bottleneck.wizard.manager model_freshservice_ticket_bottleneck_wizard gmn_freshservice.group_freshservice_manager 1 1 1 1

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
"""Package services: berisi client untuk koneksi ke Freshservice API."""
from . import gmn_freshservice_client_v01

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
"""Client untuk koneksi ke Freshservice REST API v2.
Bisa jalan dalam 2 mode: Live (panggil API beneran) atau Mock (data dummy untuk testing).
"""
import datetime
import logging
import requests
@@ -31,10 +34,15 @@ class FreshserviceClient:
_agent_cache = {}
_dept_cache = {}
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=True):
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
"""Inisialisasi koneksi ke Freshservice.
- base_url: URL instance Freshservice (misal: https://company.freshservice.com)
- api_key: token API untuk otentikasi
- use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet
"""
self.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or ""
self.use_mock = use_mock
self.use_mock = False
def get_agent_name(self, agent_id):
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
@@ -295,6 +303,13 @@ class FreshserviceClient:
# "conversations": conversations_json,
# }
def _get_live_payload(self, ticket_id):
"""Ambil data lengkap tiket dari server live Freshservice via REST API.
Memanggil beberapa endpoint:
1. /api/v2/tickets/{id}?include=stats,requester - info tiket & metrik
2. /api/v2/tickets/{id}/activities - audit trail/riwayat perubahan
3. /api/v2/tickets/{id}/conversations - riwayat pesan/chat
Mengembalikan dictionary berisi ketiga data di atas.
"""
if not self.base_url or not self.api_key:
raise UserError(
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!"

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""Package test untuk modul GMN Freshservice."""
from . import test_responsibility_rules

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""Test untuk memastikan pembagian tanggung jawab (responsibility bucket)
dan laporan tiket berjalan dengan benar.
Data expected value diambil dari tiket #9658 yang sudah disetujui.
Test ini hanya jalan kalau fixture tiket #9658 sudah di-install.
"""
from odoo.tests.common import TransactionCase
class TestResponsibilityRules(TransactionCase):
@classmethod
def setUpClass(cls):
"""Siapkan model Ticket supaya bisa dipakai di semua test."""
super().setUpClass()
cls.Ticket = cls.env['freshservice.ticket']
def _ticket_9658(self):
"""Cari tiket #9658 di database. Return kosong kalau belum ada."""
return self.Ticket.search([('external_ticket_id', '=', '9658')], limit=1)
def test_acceptance_1_eight_buckets_sum_to_total(self):
"""Cek 8 bucket tanggung jawab. Jumlah jam masing-masing bucket
harus cocok dengan nilai expected, dan total semua bucket
harus sama dengan grand total.
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
values = {
'internal': 30.70,
'it_hold': 10.74,
'customer': 31.34,
'user_after_auto_resolve': 29.89,
'needs_verification': 36.72,
'scheduled_meeting': 13.26,
'third_party': 27.22,
'resolved_idle': 0.0,
}
actual = {key: 0.0 for key in values}
for log in ticket.stage_log_ids:
key = log.override_responsibility or log.responsibility
if key in actual:
actual[key] += log.duration_hours or 0.0
for key, expected in values.items():
self.assertAlmostEqual(actual[key], expected, places=2, msg=key)
self.assertAlmostEqual(sum(actual.values()), sum(values.values()), places=2)
def test_acceptance_2_as_of_and_overrides(self):
"""Pastikan tiket punya field first_ack_at dan
first_meaningful_response_at (waktu respon pertama).
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
self.assertTrue(all(hasattr(ticket, field) for field in (
'first_ack_at', 'first_meaningful_response_at',
)))
def test_acceptance_3_bucket_invariant(self):
"""Cek invariant: total jam dari semua bucket harus sama
dengan total jam dari semua stage log. Tidak boleh ada
jam yang hilang atau dobel.
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
bucket_names = {
'internal', 'it_hold', 'customer', 'user_after_auto_resolve',
'needs_verification', 'scheduled_meeting', 'third_party',
'resolved_idle',
}
bucket_total = sum(
log.duration_hours or 0.0
for log in ticket.stage_log_ids
if (log.override_responsibility or log.responsibility) in bucket_names
)
self.assertAlmostEqual(bucket_total, sum(log.duration_hours or 0.0 for log in ticket.stage_log_ids), places=2)

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- LIST VIEW HARI LIBUR & CUTI BERSAMA -->
<record id="view_freshservice_holiday_list" model="ir.ui.view">
<field name="name">freshservice.holiday.list</field>
<field name="model">freshservice.holiday</field>
<field name="arch" type="xml">
<list string="Daftar Hari Libur &amp; Cuti Bersama" default_order="date desc">
<header>
<button name="action_open_import_wizard"
string="Impor Excel"
type="object"
class="btn-primary"
icon="fa-upload"
display="always"
groups="gmn_freshservice.group_freshservice_manager"/>
<button name="action_download_template"
string="Download Template Excel"
type="object"
class="btn-secondary"
icon="fa-download"
display="always"
groups="gmn_freshservice.group_freshservice_manager"/>
<button name="action_populate_default_holidays"
string="Muat Libur Baku (2024-2026)"
type="object"
class="btn-outline-secondary"
icon="fa-calendar-check-o"
display="always"
groups="gmn_freshservice.group_freshservice_manager"/>
</header>
<field name="date" string="Tanggal Libur"/>
<field name="name" string="Nama Hari Libur / Deskripsi"/>
<field name="holiday_type"
widget="badge"
decoration-danger="holiday_type == 'public'"
decoration-info="holiday_type == 'joint_leave'"
decoration-warning="holiday_type == 'company'"/>
<field name="year" string="Tahun" optional="show"/>
<field name="notes" string="Catatan" optional="show"/>
<field name="active" widget="boolean_toggle" string="Aktif" optional="show"/>
</list>
</field>
</record>
<!-- FORM VIEW HARI LIBUR & CUTI BERSAMA -->
<record id="view_freshservice_holiday_form" model="ir.ui.view">
<field name="name">freshservice.holiday.form</field>
<field name="model">freshservice.holiday</field>
<field name="arch" type="xml">
<form string="Hari Libur &amp; Cuti Bersama">
<header>
<button name="action_open_import_wizard"
string="Impor Excel"
type="object"
class="btn-primary"
icon="fa-upload"/>
<button name="action_download_template"
string="Download Template Excel"
type="object"
class="btn-secondary"
icon="fa-download"/>
<button name="action_populate_default_holidays"
string="Muat Libur Baku (2024-2026)"
type="object"
class="btn-outline-secondary"
icon="fa-calendar-check-o"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" string="Nama Hari Libur"/>
<h1>
<field name="name" placeholder="misal: Hari Raya Idul Fitri 1447 H"/>
</h1>
</div>
<group>
<group string="Waktu &amp; Klasifikasi">
<field name="date"/>
<field name="holiday_type" widget="radio"/>
<field name="year" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
</group>
<group string="Keterangan &amp; Legalitas">
<field name="notes" placeholder="misal: SKB 3 Menteri No. 1017 Tahun 2024..."/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- SEARCH VIEW HARI LIBUR & CUTI BERSAMA -->
<record id="view_freshservice_holiday_search" model="ir.ui.view">
<field name="name">freshservice.holiday.search</field>
<field name="model">freshservice.holiday</field>
<field name="arch" type="xml">
<search string="Cari Hari Libur">
<field name="name" string="Nama / Deskripsi"/>
<field name="date" string="Tanggal"/>
<field name="year" string="Tahun"/>
<filter name="filter_active" string="Aktif" domain="[('active', '=', True)]"/>
<filter name="filter_public" string="Hari Libur Nasional" domain="[('holiday_type', '=', 'public')]"/>
<filter name="filter_joint" string="Cuti Bersama" domain="[('holiday_type', '=', 'joint_leave')]"/>
<filter name="filter_company" string="Libur Khusus Perusahaan" domain="[('holiday_type', '=', 'company')]"/>
<separator/>
<filter name="filter_2026" string="Tahun 2026" domain="[('year', '=', 2026)]"/>
<filter name="filter_2025" string="Tahun 2025" domain="[('year', '=', 2025)]"/>
<filter name="filter_2024" string="Tahun 2024" domain="[('year', '=', 2024)]"/>
<group>
<filter name="group_by_year" string="Tahun" context="{'group_by': 'year'}"/>
<filter name="group_by_type" string="Jenis Libur" context="{'group_by': 'holiday_type'}"/>
</group>
</search>
</field>
</record>
<!-- ACTION WINDOW HARI LIBUR -->
<record id="action_freshservice_holiday" model="ir.actions.act_window">
<field name="name">Hari Libur &amp; Cuti Bersama</field>
<field name="res_model">freshservice.holiday</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_freshservice_holiday_search"/>
<field name="context">{'search_default_filter_active': 1, 'search_default_group_by_year': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Belum ada data hari libur atau cuti bersama yang terdaftar.
</p>
<p>
Klik tombol <b>Muat Libur Baku (2024-2026)</b> untuk langsung memuat data hari libur nasional Indonesia,
atau klik <b>Impor Excel</b> untuk mengunggah jadwal hari libur custom perusahaan Anda.
</p>
</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
<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 &amp; Durasi Status"
<list string="Audit Trail &amp; Status Duration"
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')">
@@ -13,9 +13,10 @@
<field name="stage_name" widget="badge"/>
<field name="responsibility"
widget="badge"
decoration-danger="responsibility == 'internal'"
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
decoration-warning="responsibility == 'customer'"
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
decoration-info="responsibility == 'scheduled_meeting'"
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
<field name="actor_name"/>
<field name="actor_type"/>
<field name="start_date" string="Waktu Mulai (UTC)"/>
@@ -36,25 +37,37 @@
<form string="Detail Audit Trail">
<sheet>
<group>
<group>
<group string="Informasi Status &amp; Tanggung Jawab">
<field name="ticket_id" readonly="1"/>
<field name="stage_name" readonly="1"/>
<field name="responsibility" readonly="1"/>
<field name="effective_status" readonly="1"/>
<field name="responsibility" readonly="1" string="Effective Responsibility"/>
<field name="automatic_responsibility" readonly="1"/>
<field name="actor_name" readonly="1"/>
<field name="actor_type" readonly="1"/>
</group>
<group>
<group string="Durasi Kerja Operasional (08:30-17:30 WIB)">
<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_duration_display" readonly="1" string="Jam Kerja Efektif"/>
<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>
<group string="Klasifikasi &amp; Manual Override (Aturan F)">
<group>
<field name="delay_cause"/>
<field name="needs_verification"/>
<field name="classification_basis"/>
</group>
<group groups="gmn_freshservice.group_freshservice_manager">
<field name="override_responsibility"/>
<field name="override_source"/>
<field name="override_reviewer"/>
<field name="override_at"/>
</group>
</group>
<notebook>
<page string="Deskripsi Aktivitas">
<field name="activity_content" readonly="1"/>
@@ -70,20 +83,20 @@
<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">
<search string="Search Activity Logs">
<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_customer" string="Waiting on User (Requester)" 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_scheduled_meeting" string="Scheduled Meeting (Waiting Together)" 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)]"/>
<filter name="filter_sla_paused" string="All SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
<filter name="filter_active" string="In Progress (Active)" domain="[('is_active', '=', True)]"/>
<separator/>
<group>
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>

View File

@@ -9,32 +9,22 @@
<header>
<button name="action_sync_from_freshservice"
type="object"
string="Tarik Data Freshservice"
string="Sync Freshservice Data"
class="btn-primary"
icon="fa-refresh"/>
icon="fa-refresh"
groups="gmn_freshservice.group_freshservice_manager"/>
<button name="action_open_bottleneck_wizard"
type="object"
string="Laporan Bottleneck (Excel)"
string="Bottleneck Report (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"/>
<label for="name" string="Ticket Subject / Issue"/>
<h1>
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
</h1>
@@ -43,47 +33,53 @@
<field name="current_responsibility" widget="badge"
decoration-danger="current_responsibility == 'internal'"
decoration-warning="current_responsibility == 'customer'"
decoration-info="current_responsibility == 'scheduled_meeting'"
decoration-muted="current_responsibility == 'third_party'"
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="col-md-2">
<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>
<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="col-md-2">
<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>
<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="col-md-2">
<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>
<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="col-md-2">
<div class="card text-center p-3 shadow-sm border-info">
<small class="text-muted fw-bold text-uppercase">Scheduled Meeting</small>
<h4 class="mb-0 text-info mt-1"><field name="shared_wait_time_display" readonly="1"/></h4>
<small class="text-secondary mt-1">Waiting Together</small>
</div>
</div>
<div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-secondary">
<small class="text-muted fw-bold text-uppercase">Third Party</small>
<h4 class="mb-0 text-secondary mt-1"><field name="third_party_time_display" readonly="1"/></h4>
<small class="text-secondary mt-1">Waiting on Vendor</small>
</div>
</div>
<div class="col-md-2">
<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>
<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>
@@ -115,9 +111,10 @@
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-danger="responsibility in ('internal','on_hold','internal_discussion')"
decoration-warning="responsibility == 'customer'"
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
decoration-info="responsibility == 'scheduled_meeting'"
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
<field name="actor_name"/>
<field name="actor_type"/>
<field name="start_date" string="Waktu Mulai (UTC)"/>
@@ -159,7 +156,11 @@
<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="shared_wait_time_days" readonly="1"/>
<field name="third_party_time_days" readonly="1"/>
<field name="resolution_time_days" readonly="1"/>
<field name="shared_wait_time_hours" invisible="1"/>
<field name="third_party_time_hours" invisible="1"/>
<!-- Field jam disembunyikan (hide) -->
<field name="first_response_time_hours" invisible="1"/>
<field name="total_agent_time_hours" invisible="1"/>
@@ -180,7 +181,7 @@
<field name="name">freshservice.ticket.tree</field>
<field name="model">freshservice.ticket</field>
<field name="arch" type="xml">
<list string="Daftar Tiket Freshservice"
<list string="Freshservice Tickets"
decoration-danger="current_responsibility == 'internal'"
decoration-warning="current_responsibility == 'customer'"
decoration-muted="status in ('resolved', 'closed')">
@@ -204,6 +205,8 @@
decoration-success="current_responsibility == 'closed'"/>
<field name="total_agent_time_display"/>
<field name="total_customer_time_display"/>
<field name="shared_wait_time_display" optional="show"/>
<field name="third_party_time_display" optional="show"/>
<field name="first_response_time_display"/>
<field name="responder_name"/>
<field name="requester_name"/>
@@ -297,17 +300,17 @@
<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')]"/>
<filter name="filter_waiting_user" string="Waiting on Requester (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'])]"/>
<filter name="filter_open_progress" string="Active Tickets (Not Completed)" domain="[('status', 'in', ['open', 'pending'])]"/>
<filter name="filter_resolved" string="Completed (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_responder" string="Agent (IT)" context="{'group_by': 'responder_name'}"/>
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
</group>
</search>

View File

@@ -35,15 +35,31 @@ action = model.action_open_and_fetch_tickets()
action="action_freshservice_ticket_bottleneck_wizard"
sequence="30"/>
<!-- CONFIGURATION MENU -->
<!-- CONFIGURATION MENU (Manager Only) -->
<menuitem id="menu_freshservice_config_parent"
name="Konfigurasi"
parent="menu_freshservice_root"
groups="gmn_freshservice.group_freshservice_manager"
sequence="90"/>
<menuitem id="menu_freshservice_config_settings"
name="Pengaturan Integrasi"
parent="menu_freshservice_config_parent"
action="action_freshservice_config_settings"
groups="gmn_freshservice.group_freshservice_manager"
sequence="10"/>
<menuitem id="menu_freshservice_holiday"
name="Hari Libur &amp; Cuti Bersama"
parent="menu_freshservice_config_parent"
action="action_freshservice_holiday"
groups="gmn_freshservice.group_freshservice_manager"
sequence="20"/>
<menuitem id="menu_freshservice_holiday_import_wizard"
name="Impor Hari Libur (Excel)"
parent="menu_freshservice_config_parent"
action="action_freshservice_holiday_import_wizard"
groups="gmn_freshservice.group_freshservice_manager"
sequence="25"/>
</odoo>

View File

@@ -7,7 +7,7 @@
<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">
<app data-string="Freshservice Audit" string="Freshservice Audit" name="gmn_freshservice">
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
<field name="freshservice_url"/>
@@ -21,12 +21,11 @@
<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"/>
<setting id="freshservice_connection_setting" help="Uji koneksi ke Live Freshservice API">
<div class="mt8">
<button name="action_test_freshservice_connection"
type="object"
string="Tes Koneksi / Cek Status"
string="Tes Koneksi Live API"
class="btn-primary"
icon="fa-plug"/>
</div>
@@ -42,6 +41,6 @@
<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>
<field name="context">{'module': 'gmn_freshservice'}</field>
</record>
</odoo>

View File

@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
"""Package wizard untuk laporan bottleneck tiket dan utilitas impor Freshservice."""
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
from . import gmn_freshservice_holiday_import_wizard_v01

View File

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

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- FORM VIEW WIZARD IMPOR HARI LIBUR DARI EXCEL -->
<record id="view_freshservice_holiday_import_wizard_form" model="ir.ui.view">
<field name="name">freshservice.holiday.import.wizard.form</field>
<field name="model">freshservice.holiday.import.wizard</field>
<field name="arch" type="xml">
<form string="Impor Hari Libur dari Excel">
<div class="alert alert-info" role="status">
<p class="mb-1 font-weight-bold">
<i class="fa fa-info-circle me-1"/> Petunjuk Impor Spreadsheet Excel:
</p>
<ul class="mb-0 ps-3">
<li>Gunakan file berformat <b>.xlsx</b> (Microsoft Excel / Google Sheets).</li>
<li>Pastikan kolom <b>Tanggal Libur</b> terisi dengan format standar <code>YYYY-MM-DD</code> (contoh: <code>2026-08-17</code>).</li>
<li>Kolom <b>Jenis Libur</b> dapat diisi: <i>Hari Libur Nasional</i>, <i>Cuti Bersama</i>, atau <i>Libur Khusus Perusahaan</i>.</li>
</ul>
</div>
<div class="mb-3">
<button name="action_download_template"
type="object"
string="Download Template Excel (.xlsx)"
class="btn btn-outline-primary"
icon="fa-download"/>
</div>
<group>
<group string="Unggah Dokumen">
<field name="file" filename="filename" string="File Excel (.xlsx)"/>
<field name="filename" invisible="1"/>
</group>
<group string="Metode Penanganan Data">
<field name="import_mode" widget="radio"/>
</group>
</group>
<footer>
<button name="action_import_excel"
string="Mulai Impor"
type="object"
class="btn-primary"
icon="fa-upload"/>
<button string="Batal"
class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ACTION WIZARD IMPOR -->
<record id="action_freshservice_holiday_import_wizard" model="ir.actions.act_window">
<field name="name">Impor Hari Libur dari Excel</field>
<field name="res_model">freshservice.holiday.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -1,4 +1,9 @@
# -*- coding: utf-8 -*-
"""Wizard untuk generate laporan Excel analisis bottleneck tiket Freshservice.
Bisa bikin 2 jenis laporan:
1. Detail bottleneck satu tiket (breakdown per stage siapa yang lama)
2. Ranking multi-tiket (perbandingan banyak tiket sekaligus)
"""
import io
import re
import base64
@@ -11,7 +16,12 @@ from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days
_logger = logging.getLogger(__name__)
class FreshserviceTicketBottleneckWizard(models.TransientModel):
"""Wizard popup untuk bikin laporan Excel bottleneck.
User pilih tiket, filter tanggal/status, lalu klik Generate.
Hasilnya file Excel yang bisa langsung diunduh.
"""
_name = 'freshservice.ticket.bottleneck.wizard'
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
@@ -43,6 +53,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
date_from = fields.Date(string='Dari Tanggal Dibuat')
date_to = fields.Date(string='Sampai Tanggal Dibuat')
as_of = fields.Datetime(
string='Batas Waktu Cut-Off (As-Of)',
help='Batas waktu simulasi retrospektif (opsional). '
'Jika diisi, perhitungan durasi seluruh stage log dipotong sampai timestamp ini. '
'Kosongkan untuk perhitungan real-time hingga saat ini.',
)
status_filter = fields.Selection([
('all', 'Semua Status'),
@@ -67,8 +83,22 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
file_name = fields.Char(string='Nama File', readonly=True)
bottleneck_summary_html = fields.Html(string='Ringkasan Analisis Bottleneck', readonly=True)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_model = self.env.context.get('active_model')
active_id = self.env.context.get('active_id')
if active_model == 'freshservice.ticket' and active_id:
ticket = self.env['freshservice.ticket'].browse(active_id)
if ticket.exists():
res['ticket_id'] = ticket.id
if ticket.external_ticket_id:
res['external_ticket_id'] = ticket.external_ticket_id
return res
@api.onchange('ticket_id')
def _onchange_ticket_id(self):
"""Kalau user pilih tiket dari dropdown, otomatis isi nomor ID Freshservice-nya."""
if self.ticket_id and self.ticket_id.external_ticket_id:
self.external_ticket_id = self.ticket_id.external_ticket_id
@@ -80,6 +110,28 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _effective_responsibility(self, log):
"""Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review,
kalau belum pakai hasil deteksi otomatis.
"""
return log.override_responsibility or log.responsibility
def _clipped_stage_interval(self, log, report_as_of):
"""Potong interval stage log sesuai cut-off laporan.
Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu.
"""
start = log.start_date
if not start:
return False, False
if report_as_of and start >= report_as_of:
return False, False
end = log.end_date or report_as_of
if report_as_of and (not end or end > report_as_of):
end = report_as_of
if not end or end <= start:
return False, False
return start, end
def _ensure_ticket_details(self, ticket):
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
if self.auto_sync_detail:
@@ -109,6 +161,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
target_ticket.action_sync_from_freshservice()
except Exception as e:
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
else :
try:
target_ticket.action_sync_from_freshservice()
except Exception as e:
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
if not target_ticket:
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
@@ -207,6 +264,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
})
scheduled_meeting_fmt = workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1,
'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter'
})
kpi_box_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
@@ -255,13 +317,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
)
else:
summary_html = self._build_ranking_sheets(
workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
)
workbook.close()
@@ -309,7 +373,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
):
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
@@ -324,19 +389,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws1.set_column('I:I', 14)
# Header Title
ws1.merge_range('B2:H2', f'LAPORAN ANALISIS TIKET #{t.external_ticket_id}', title_fmt)
ws1.merge_range('B2:H2', f'TICKET ANALYSIS REPORT #{t.external_ticket_id}', title_fmt)
cutoff_info = ""
if self.as_of:
from datetime import timedelta as _td
as_of_wib = (self.as_of + _td(hours=7)).strftime('%d-%m-%Y %H:%M')
cutoff_info = f" | Cut-off As-Of: {as_of_wib} WIB"
ws1.write(
'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}{cutoff_info}',
subtitle_fmt
)
# Info Header Tiket
ws1.merge_range('B5:H5', 'Informasi Tiket', section_fmt)
ws1.merge_range('B5:H5', 'Ticket Information', section_fmt)
ws1.write('B6', 'ID Freshservice', td_fmt)
ws1.write('C6', f'#{t.external_ticket_id}', td_fmt)
ws1.write('D6', 'Status Saat Ini', td_fmt)
ws1.write('D6', 'Current Status', td_fmt)
st_fmt = status_formats.get(t.status, td_center_fmt)
ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt)
@@ -348,14 +418,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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('B9', 'Agent IT Penanggung Jawab', td_fmt)
ws1.write('C9', t.responder_name or '-', td_fmt)
ws1.write('D9', 'Prioritas', td_fmt)
ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt)
ws1.write('B10', 'Waktu Dibuat', td_fmt)
ws1.write('B10', 'Created At', td_fmt)
ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt)
ws1.write('D10', 'Waktu Selesai', td_fmt)
ws1.write('D10', 'Resolved At', td_fmt)
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
# KPI BOXES
@@ -404,292 +474,404 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
'align': 'center', 'valign': 'vcenter'
})
# Warna KPI mengikuti responsibility: internal merah, customer orange,
# scheduled meeting biru, dan third party abu-abu.
kpi_internal_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA'
})
kpi_customer_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F'
})
kpi_meeting_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9'
})
kpi_third_party_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7'
})
kpi_internal_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA',
'align': 'center', 'valign': 'vcenter'
})
kpi_customer_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter'
})
kpi_meeting_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9',
'align': 'center', 'valign': 'vcenter'
})
kpi_third_party_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7',
'align': 'center', 'valign': 'vcenter'
})
ws1.write('B13', 'First Response', kpi_wh_label)
ws1.write('C13', 'Total Waktu', kpi_wh_label)
ws1.write('D13', '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('D13', 'Agent Responsibility', kpi_internal_label)
ws1.write('E13', 'Requester Responsibility', kpi_customer_label)
ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label)
ws1.write('G13', 'Third Party Responsibility', kpi_third_party_label)
ws1.write('H13', 'Catatan Rumus', kpi_wh_label)
# Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat)
wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0
wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0
report_as_of = 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
report_as_of = self.as_of or fields.Datetime.now()
wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0
wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 0.0
stage_wh_map = {}
stage_resp_map = {}
for s in t.stage_log_ids:
# 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)
start, end = self._clipped_stage_interval(s, report_as_of)
if not start:
stage_wh_map[s.id] = 0.0
continue
s_wh = compute_working_hours(self.env, start, end)
resp = self._effective_responsibility(s)
stage_wh_map[s.id] = s_wh
if s.responsibility == 'internal':
stage_resp_map[s.id] = resp
if resp == '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':
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
wh_it_hold += s_wh
elif resp == 'customer':
wh_cust += s_wh
elif s.responsibility == 'scheduled_meeting':
elif resp == 'user_after_auto_resolve':
wh_user_auto += s_wh
elif resp == 'needs_verification':
wh_needs_verif += s_wh
elif resp == 'scheduled_meeting':
wh_meeting += s_wh
elif s.responsibility == 'third_party':
elif resp in ('third_party', 'external_activity'):
wh_vendor += s_wh
elif s.responsibility == 'external_activity':
wh_other_paused += s_wh
elif s.responsibility == 'resolved_idle':
elif resp == '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_agent = wh_it_direct + wh_it_hold
wh_requester = wh_cust + wh_user_auto + wh_needs_verif
wh_total = sum(stage_wh_map.values())
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
ws1.write('C14', format_working_duration(wh_total), kpi_box_val)
ws1.write('D14', format_working_duration(wh_agent), kpi_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)
ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val)
ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val)
ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val)
ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val)
ws1.write('H14', 'Monday-Friday, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 hours/day)\nDB UTC → +7 WIB', kpi_box_val)
# 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)
# RESPONSIBILITY RECAP — must match the Sheet 2 total.
ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt)
ws1.write('B17', 'Responsibility Bucket', th_fmt)
ws1.merge_range('C17:F17', 'Operational Description', th_fmt)
ws1.write('G17', 'Working Hours', th_fmt)
ws1.write('H17', 'Effective Workdays', th_fmt)
ws1.write('I17', '% of Load', th_fmt)
recap_rows = [
('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),
('Agent Active Work', 'Direct Agent handling while the ticket is Open or In Progress.', wh_it_direct),
('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold),
('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust),
('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto),
('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif),
('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting),
('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor),
('Resolved / Idle Time', 'Resolved or closed time without a further attributable action.', wh_resolved_idle),
]
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)))
# xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18.
# Ini menghindari satu baris kosong sebelum rekap pertama.
customer_recap_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'left', 'valign': 'vcenter'
})
customer_recap_number_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00'
})
customer_recap_pct_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00%'
})
for recap_row, (label, description, hours) in enumerate(recap_rows, start=17):
is_customer = label.startswith('Waiting on Requester') or label.startswith('Waiting on User')
label_fmt = (
bn_badge_fmt if label.startswith('Agent ') else
customer_recap_number_fmt if is_customer else
scheduled_meeting_fmt if label.startswith('Scheduled') else
sla_paused_fmt or td_center_fmt
)
ws1.write(recap_row, 1, label, label_fmt)
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
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)
excel_row = recap_row + 1
ws1.write_number(recap_row, 6, round(hours, 2), td_dec_fmt)
ws1.write_number(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
ws1.write_formula(recap_row, 8, f'=IF($G$26=0,0,G{excel_row}/$G$26)', td_pct_fmt)
# Grand total: Excel formula rather than a static number.
ws1.write(25, 1, 'Grand Total Ticket Lifetime', section_fmt)
ws1.merge_range(25, 2, 25, 5, 'Total across all eight responsibility buckets.', section_fmt)
ws1.write_formula(25, 6, '=SUM(G18:G25)', td_dec_fmt)
ws1.write_formula(25, 7, '=SUM(H18:H25)', td_dec_fmt)
ws1.write_formula(25, 8, '=SUM(I18:I25)', td_pct_fmt)
# Conservative IT workload range: the maximum includes pending user verification.
ws1.write('B27', 'Estimated Agent Workload Range', section_fmt)
ws1.write('C27', 'Minimum (Agent Active Work + Agent Hold)', td_fmt)
ws1.write_formula('D27', '=G18+G19', td_dec_fmt)
ws1.write('E27', 'Maximum (+ User Verification)', td_fmt)
ws1.write_formula('F27', '=D27+G22', td_dec_fmt)
# Aggregate ranking by the party responsible for the next action.
ws1.merge_range('B29:E29', 'AGGREGATE BOTTLENECK RANKING BY PARTY', section_fmt)
aggregate_rows = [
('Agent', wh_it_direct + wh_it_hold, 'Active work and internal hold.'),
('Requester', wh_cust + wh_user_auto + wh_needs_verif, 'Waiting on requester, auto-resolve & user verification.'),
('Shared', wh_meeting, 'Waiting for a scheduled meeting.'),
('Third Party', wh_vendor, 'Vendor or external party.')
]
if wh_resolved_idle > 0:
aggregate_rows.append(('Resolved / Idle', wh_resolved_idle, 'Resolved or closed idle time.'))
for col_idx, header in enumerate(['Party', 'Working Hours', 'Workdays', '% of Load'], start=1):
ws1.write(29, col_idx, header, th_fmt)
for aggregate_row, (party, hours, note) in enumerate(aggregate_rows, start=30):
ws1.write(aggregate_row, 1, party, td_fmt)
ws1.write_number(aggregate_row, 2, round(hours, 2), td_dec_fmt)
ws1.write_number(aggregate_row, 3, round(hours / 8.0, 2), td_dec_fmt)
ws1.write_formula(aggregate_row, 4, f'=IF($G$26=0,0,C{aggregate_row + 1}/$G$26)', td_pct_fmt)
# TABEL RANKING BOTTLENECK PER STAGE
# 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']
ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt)
headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status']
for col_idx, h in enumerate(headers_bn, start=1):
ws1.write(29, col_idx, h, th_fmt)
ws1.write(36, col_idx, h, th_fmt)
sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True)
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
row = 30
primary_bn_text = "Tidak terdeteksi delay signifikan."
primary_bn_actor = "-"
primary_bn_stage = "-"
primary_bn_days = 0
row = 37
for rank_idx, stage in enumerate(sorted_stages, start=1):
stage_wh = stage_wh_map.get(stage.id, 0.0)
stage_wd_eff = round(stage_wh / 8.0, 2)
pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0
if stage.responsibility == 'internal':
resp_label = 'Waiting on Us (Internal)'
resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage))
if resp == 'internal':
resp_label = 'Agent Active Work'
resp_fmt = bn_badge_fmt
elif stage.responsibility == 'customer':
resp_label = 'Waiting on User (Requester)'
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
resp_label = 'Agent Hold & Discussion'
resp_fmt = bn_badge_fmt
elif resp == 'customer':
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
elif 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)'
elif resp == 'user_after_auto_resolve':
resp_label = 'Waiting on User After Auto-Resolve'
resp_fmt = normal_badge_fmt
elif resp == 'needs_verification':
resp_label = 'Waiting on User Verification'
resp_fmt = normal_badge_fmt
elif resp == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Shared)'
resp_fmt = scheduled_meeting_fmt
elif resp in ('third_party', 'external_activity'):
resp_label = 'Waiting on Third Party'
resp_fmt = sla_paused_fmt or td_center_fmt
elif resp == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
resp_fmt = td_center_fmt
else:
resp_label = 'Resolved (Ticket Closed/Idle)'
resp_fmt = sla_paused_fmt or td_center_fmt
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
is_primary = (rank_idx == 1 and stage_wh > 0.0)
ws1.write(row, 1, f"#{rank_idx}", bn_badge_fmt if is_primary else td_center_fmt)
ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt)
ws1.write(row, 2, stage.stage_name or '-', td_fmt)
ws1.write(row, 3, resp_label, resp_fmt)
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
ws1.write(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)
ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt)
ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt)
ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt)
ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt)
row += 1
# 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)
# SHEET 2: AUDIT TRAIL & CHRONOLOGY
ws2 = workbook.add_worksheet('Audit Trail & Chronology')
ws2.set_column('A:A', 4)
ws2.set_column('B:B', 6)
ws2.set_column('C: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.set_column('C:D', 18)
ws2.set_column('E:F', 24)
ws2.set_column('G:G', 28)
ws2.set_column('H:H', 14)
ws2.set_column('I:I', 20)
ws2.set_column('J:K', 16)
ws2.set_column('L:L', 20)
ws2.set_column('M:M', 32)
ws2.set_column('N:N', 16)
ws2.set_column('O:O', 35)
ws2.set_column('P:P', 55)
ws2.merge_range('B2:L2', f'KRONOLOGI LENGKAP AUDIT TRAIL TIKET #{t.external_ticket_id}', title_fmt)
ws2.merge_range('B2:P2', f'FULL AUDIT TRAIL CHRONOLOGY — TICKET #{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'
'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name',
'Effective Workdays', 'Working Hours', 'Delay Cause',
'Classification Basis', 'User Verification Required',
'Activity Details', 'Reply / Message Content'
]
for col_idx, h in enumerate(headers_ws2, start=1):
ws2.write(3, col_idx, h, th_fmt)
from datetime import timedelta as _td
def _to_wib_str(dt):
if not dt:
return 'Active'
try:
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
return str(dt)
row2 = 4
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)'
current_idx2 = 0
for log in t.stage_log_ids.sorted('start_date'):
start, end = self._clipped_stage_interval(log, report_as_of)
if not start:
continue
current_idx2 += 1
resp = stage_resp_map.get(log.id, self._effective_responsibility(log))
if resp == 'internal':
resp_label = 'Agent Active Work'
resp_fmt = bn_badge_fmt
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
resp_label = 'Agent Hold & Discussion'
resp_fmt = bn_badge_fmt
elif resp == 'customer':
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
elif resp == 'user_after_auto_resolve':
resp_label = 'Waiting on User After Auto-Resolve'
resp_fmt = normal_badge_fmt
elif resp == 'needs_verification':
resp_label = 'Waiting on User Verification'
resp_fmt = normal_badge_fmt
elif resp == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Shared)'
resp_fmt = scheduled_meeting_fmt
elif resp in ('third_party', 'external_activity'):
resp_label = 'Waiting on Third Party'
resp_fmt = sla_paused_fmt or td_center_fmt
elif resp == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
resp_fmt = td_center_fmt
else:
resp_label = 'Resolved (Ticket Closed/Idle)'
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main:
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
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)
wh = stage_wh_map.get(log.id, 0.0)
wd_eff = round(wh / 8.0, 2)
cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None')
verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No'
# 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)
effective_end = end if (self.as_of and end) else log.end_date
ws2.write(row2, 1, current_idx2, td_center_fmt)
ws2.write(row2, 2, _to_wib_str(start), td_center_fmt)
ws2.write(row2, 3, _to_wib_str(effective_end), td_center_fmt)
ws2.write(row2, 4, log.stage_name or '-', td_fmt)
ws2.write(row2, 5, 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)
ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt)
ws2.write(row2, 6, resp_label, resp_fmt)
ws2.write(row2, 7, log.actor_type or '-', td_center_fmt)
ws2.write(row2, 8, log.actor_name or '-', td_fmt)
ws2.write_number(row2, 9, wd_eff, td_dec_fmt)
ws2.write_number(row2, 10, round(wh, 2), td_dec_fmt)
ws2.write(row2, 11, cause_label, td_center_fmt)
ws2.write(row2, 12, log.classification_basis or '-', td_fmt)
ws2.write(row2, 13, verif_str, td_center_fmt)
ws2.write(row2, 14, self._strip_html(raw_act_main) or '-', td_fmt)
ws2.write(row2, 15, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
row2 += 1
# Grand total 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)
# Grand total using Excel formulas
ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt)
ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt)
ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', td_dec_fmt)
ws2.merge_range(row2, 11, row2, 15, 'Matches Sheet 1: all stage logs calculated using effective business hours.', td_fmt)
# SHEET 3: RIWAYAT PERCAKAPAN
ws3 = workbook.add_worksheet('Percakapan & Respon')
# SHEET 3: CONVERSATIONS & NOTES
ws3 = workbook.add_worksheet('Conversations & Notes')
ws3.set_column('A:A', 5)
ws3.set_column('B:B', 8)
ws3.set_column('C:C', 18)
ws3.set_column('C:C', 20)
ws3.set_column('D:D', 22)
ws3.set_column('E:E', 24)
ws3.set_column('F:F', 55)
ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt)
headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan']
ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt)
headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content']
for col_idx, h in enumerate(headers_ws3, start=1):
ws3.write(3, col_idx, h, th_fmt)
row3 = 4
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)
current_idx3 = 0
for conv in t.conversation_ids.sorted('created_at'):
if report_as_of and conv.created_at and conv.created_at > report_as_of:
continue
current_idx3 += 1
sender_type_label = 'Requester (User)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent (IT)'
conv_wib_str = _to_wib_str(conv.created_at)
ws3.write(row3, 1, current_idx3, td_center_fmt)
ws3.write(row3, 2, conv_wib_str, td_center_fmt)
ws3.write(row3, 3, sender_type_label, normal_badge_fmt if conv.incoming else bn_badge_fmt)
ws3.write(row3, 4, conv.user_name or '-', td_fmt)
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
row3 += 1
# Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard
# HTML summary for wizard pop-up dialog
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>
<h4 class="alert-heading fw-bold mb-2">📊 Ticket Bottleneck Analysis #{t.external_ticket_id}</h4>
<p class="mb-1"><strong>Subject:</strong> {t.name or '-'}</p>
<p class="mb-2"><strong>Requester:</strong> {t.requester_name or '-'} | <strong>Department:</strong> {t.department_name or '-'} | <strong>Agent Agent:</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>
<small class="text-muted d-block">TOTAL DURATION</small>
<strong class="text-dark fs-5">{t.resolution_time_days} Days</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>
<small class="text-danger d-block fw-bold">AGENT RESPONSIBILITY</small>
<strong class="text-danger fs-5">{t.total_agent_time_days} Days</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>
<small class="text-warning d-block fw-bold">REQUESTER RESPONSIBILITY</small>
<strong class="text-warning fs-5">{t.total_customer_time_days} Days</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>
<strong class="text-primary fs-5">{t.first_response_time_days} Days</strong>
</div>
</div>
</div>
</div>
"""
return html_out
# <div class="alert alert-danger mb-0">
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
# {primary_bn_text}
@@ -699,10 +881,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats,
sla_paused_fmt=None, scheduled_meeting_fmt=None
):
"""Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim)."""
ws = workbook.add_worksheet('Ranking Bottleneck Tiket')
scheduled_meeting_fmt = scheduled_meeting_fmt or sla_paused_fmt or td_center_fmt
ws = workbook.add_worksheet('Ticket Bottleneck Ranking')
ws.set_column('A:A', 5)
ws.set_column('B:B', 8)
ws.set_column('C:C', 14)
@@ -721,20 +905,20 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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.merge_range('B2:Q2', 'BOTTLENECK RANKING & SLA EVALUATION REPORT', title_fmt)
ws.write(
'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
f'Printed: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Printed by: {self.env.user.name}',
subtitle_fmt
)
headers = [
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT',
'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent',
'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)'
'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays',
'Agent Time (Calendar Days)', 'Agent Time (Working Hours)',
'User Time (Calendar Days)', 'User Time (Working Hours)',
'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)'
]
for col_idx, h in enumerate(headers, start=1):
ws.write(4, col_idx, h, th_fmt)
@@ -753,7 +937,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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_resp = 'Agent Active Work' if top_stage[0].responsibility == 'internal' else 'Waiting on Requester'
top_days = top_stage[0].duration_days
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
@@ -763,16 +947,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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':
resp = s.override_responsibility or s.responsibility
if resp in ('internal', 'it_hold', 'on_hold', 'internal_discussion'):
t_wh_agent += s_wh
elif s.responsibility == 'customer':
elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'):
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)")
slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Days / {t_wh_total:.1f} Working Hours)")
ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt)
ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt)
@@ -795,7 +979,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
row += 1
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET
ws2 = workbook.add_worksheet('Detail Stage Log Semua Tiket')
ws2 = workbook.add_worksheet('All Tickets Stage Logs')
ws2.set_column('A:A', 5)
ws2.set_column('B:B', 14)
ws2.set_column('C:C', 22)
@@ -810,13 +994,13 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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)
ws2.merge_range('B2:M2', 'ALL TICKET STAGE TRANSITIONS & DURATIONS', 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'
'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Responsibility', 'Actor',
'Calendar Days', 'Calendar Hours',
'Working Hours (08:30-17:30)', 'Effective Workdays',
'Activity Details', 'Reply / Message Content'
]
for col_idx, h in enumerate(headers2, start=1):
ws2.write(3, col_idx, h, th_fmt)
@@ -825,15 +1009,23 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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)'
resp_label = 'Agent Active Work'
elif log.responsibility == 'customer':
resp_label = 'Customer (Waiting on User)'
elif log.responsibility == 'on_hold':
resp_label = 'On Hold (SLA is Paused)'
resp_label = 'Waiting on Requester'
elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'):
resp_label = 'Agent Hold & Discussion'
elif log.responsibility == 'third_party':
resp_label = 'Waiting on Third Party (SLA is Paused)'
resp_label = 'Waiting on Third Party'
elif log.responsibility == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Shared)'
elif log.responsibility == 'user_after_auto_resolve':
resp_label = 'Waiting on User After Auto-Resolve'
elif log.responsibility == 'needs_verification':
resp_label = 'Waiting on User Verification'
elif log.responsibility == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
else:
resp_label = 'Waiting on Internal Discussion (SLA is Paused)'
resp_label = 'Waiting on Requester'
msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main:
@@ -846,7 +1038,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
from datetime import timedelta as _td2
def _wib(dt):
if not dt:
return 'Aktif'
return 'Active'
try:
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
@@ -856,7 +1048,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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)))
resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'it_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility in ('customer', 'user_after_auto_resolve', 'needs_verification') else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt)))
ws2.write(row2, 5, resp_label, resp_fmt)
ws2.write(row2, 6, log.actor_name or '-', td_fmt)
ws2.write(row2, 7, calc_log_days, td_num_fmt)
ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
@@ -869,15 +1062,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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>
<h4 class="alert-heading fw-bold mb-2">📋 Multi-Ticket Bottleneck Ranking Report Generated</h4>
<p class="mb-1">Total Evaluated Tickets: <strong>{len(sorted_tickets)} Tickets</strong></p>
<p class="mb-2">Average Resolution Time: <strong>{avg_days:.1f} Calendar Days</strong></p>
<hr class="my-2"/>
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tiket dengan Durasi Terlama:</p>
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tickets with Longest Duration:</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>
<small class="text-muted">Excel file is ready for team evaluation and management meetings.</small>
</div>
"""
return html_out
return html_out

View File

@@ -25,14 +25,14 @@
</group>
<!-- MODE SINGLE TICKET -->
<!-- <group string="Parameter Tiket Tunggal" invisible="report_type != 'single'"> -->
<group>
<group>
<group string="Identitas Tiket">
<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 string="Simulasi Cut-Off &amp; Opsi">
<field name="as_of" placeholder="Kosongkan jika real-time s/d saat ini"/>
<field invisible="True" name="auto_sync_detail"/>
</group>
</group>
@@ -44,6 +44,7 @@
</group>
<group>
<field name="status_filter"/>
<field name="as_of" string="Batas Cut-Off (As-Of)" placeholder="Kosongkan jika real-time s/d saat ini"/>
<field name="auto_sync_detail"/>
</group>
<group colspan="2">