Compare commits

...

4 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
40 changed files with 1198 additions and 421 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 -*- # -*- coding: utf-8 -*-
"""Modul utama GMN Freshservice.
Import semua sub-package: models, services, dan wizard.
"""
from . import models from . import models
from . import services from . import services
from . import wizard from . import wizard

View File

@@ -20,10 +20,13 @@ Modul untuk melacak audit trail tiket dan memisahkan durasi penanganan:
'mail', 'mail',
], ],
'data': [ 'data': [
'security/gmn_freshservice_security_v01.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/gmn_demo_data_v01.xml', 'data/gmn_demo_data_v01.xml',
'views/gmn_res_config_settings_views_v01.xml', 'views/gmn_res_config_settings_views_v01.xml',
'views/gmn_freshservice_ticket_stage_log_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', 'wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml',
'views/gmn_freshservice_ticket_views_v01.xml', 'views/gmn_freshservice_ticket_views_v01.xml',
'views/gmn_menu_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 -*- # -*- 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_res_config_settings_v01
from . import gmn_freshservice_holiday_v01
from . import gmn_freshservice_ticket_stage_log_v01 from . import gmn_freshservice_ticket_stage_log_v01
from . import gmn_freshservice_ticket_conversation_v01 from . import gmn_freshservice_ticket_conversation_v01
from . import gmn_freshservice_ticket_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 -*- # -*- 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 from odoo import models, fields
class FreshserviceTicketConversation(models.Model): 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' _description = 'Percakapan & Balasan Tiket Freshservice'
_order = 'created_at asc, id asc' _order = 'created_at asc, id asc'
@@ -32,5 +38,9 @@ class FreshserviceTicketConversation(models.Model):
created_at = fields.Datetime(string='Waktu Kirim') created_at = fields.Datetime(string='Waktu Kirim')
def _compute_sender_type(self): 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: for rec in self:
rec.sender_type = 'customer' if rec.incoming else 'agent' rec.sender_type = 'customer' if rec.incoming else 'agent'

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- 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 datetime import timedelta, datetime, time
from odoo import models, fields, api from odoo import models, fields, api
@@ -70,10 +73,40 @@ _INDONESIA_HOLIDAYS = {
def _holiday_dates(env, start_date, end_date): def _holiday_dates(env, start_date, end_date):
"""Return public leave dates combining Indonesian public holidays with Odoo's calendar leaves.""" """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__'): Prioritas Pengambilan Data:
return res 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: try:
leaves = env['resource.calendar.leaves'].sudo().search([ leaves = env['resource.calendar.leaves'].sudo().search([
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)), ('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
@@ -84,6 +117,7 @@ def _holiday_dates(env, start_date, end_date):
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date()) res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
except Exception: except Exception:
pass pass
return res return res
@@ -240,6 +274,11 @@ def format_duration_human(hours_float):
return f"{days} Hari" return f"{days} Hari"
class FreshserviceTicketStageLog(models.Model): 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' _name = 'freshservice.ticket.stage.log'
_description = 'Audit Trail Riwayat Status & Durasi Tiket' _description = 'Audit Trail Riwayat Status & Durasi Tiket'
_order = 'start_date asc, id asc' _order = 'start_date asc, id asc'
@@ -275,7 +314,7 @@ class FreshserviceTicketStageLog(models.Model):
delay_cause = fields.Selection([ delay_cause = fields.Selection([
('it', 'Agent'), ('user', 'User'), ('none', 'None'), ('it', 'Agent'), ('user', 'User'), ('none', 'None'),
], string='Delay Cause', default='none') ], string='Delay Cause', default='none')
automatic_responsibility = fields.Selection(related='responsibility', readonly=True) automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True)
override_responsibility = fields.Selection([ override_responsibility = fields.Selection([
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'), ('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'), ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
@@ -345,11 +384,15 @@ class FreshserviceTicketStageLog(models.Model):
@api.depends('end_date') @api.depends('end_date')
def _compute_is_active(self): def _compute_is_active(self):
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
for rec in self: for rec in self:
rec.is_active = not bool(rec.end_date) rec.is_active = not bool(rec.end_date)
@api.depends('start_date', 'end_date', 'is_active') @api.depends('start_date', 'end_date', 'is_active')
def _compute_working_metrics(self): 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: for rec in self:
if rec.start_date: if rec.start_date:
wh = compute_working_hours(self.env, rec.start_date, rec.end_date) wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
@@ -363,6 +406,9 @@ class FreshserviceTicketStageLog(models.Model):
@api.depends('start_date', 'end_date') @api.depends('start_date', 'end_date')
def _compute_duration(self): def _compute_duration(self):
"""Hitung durasi dalam hari kalender dan jam total.
Kalau stage masih aktif, hitung sampai waktu sekarang.
"""
for rec in self: for rec in self:
if rec.start_date: if rec.start_date:
days = compute_calendar_days(self.env, rec.start_date, rec.end_date) days = compute_calendar_days(self.env, rec.start_date, rec.end_date)

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Model utama Freshservice Ticket.
Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API.
"""
import re import re
import logging import logging
from datetime import datetime, timedelta, timezone 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__) _logger = logging.getLogger(__name__)
class FreshserviceTicket(models.Model): 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' _name = 'freshservice.ticket'
_description = 'Freshservice Ticket with Audit Trail' _description = 'Freshservice Ticket with Audit Trail'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
@@ -212,6 +220,12 @@ class FreshserviceTicket(models.Model):
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_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): 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: for rec in self:
# 1. Hitung First Response # 1. Hitung First Response
if rec.ticket_created_at and rec.first_responded_at: if rec.ticket_created_at and rec.first_responded_at:
@@ -302,7 +316,9 @@ class FreshserviceTicket(models.Model):
} }
def action_assign_to_me(self): 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() self.ensure_one()
current_user = self.env.user current_user = self.env.user
vals = {'user_id': current_user.id} vals = {'user_id': current_user.id}
@@ -348,10 +364,12 @@ class FreshserviceTicket(models.Model):
return False, "" return False, ""
def action_sync_from_freshservice(self): def action_sync_from_freshservice(self):
""" """Tarik data lengkap tiket dari Freshservice API.
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi). Proses:
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan. 1. Ambil detail tiket (subject, status, requester, dll)
Mendukung concurrency row-lock untuk keamanan multi-user. 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() self.ensure_one()
@@ -959,7 +977,9 @@ class FreshserviceTicket(models.Model):
@api.model @api.model
def _extract_ticket_ids_from_domain(self, domain): 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 = [] ticket_ids = []
if not domain: if not domain:
return ticket_ids return ticket_ids
@@ -982,7 +1002,9 @@ class FreshserviceTicket(models.Model):
@api.model @api.model
def _fetch_and_create_ticket_by_external_id(self, ticket_id): 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, ''): if ticket_id in (None, ''):
return self.browse() return self.browse()
@@ -1016,10 +1038,8 @@ class FreshserviceTicket(models.Model):
@api.model @api.model
def action_open_and_fetch_tickets(self, tiket_id = None): def action_open_and_fetch_tickets(self, tiket_id = None):
""" """Dipanggil saat user klik menu 'Daftar Tiket'.
Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket', Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list.
lalu langsung membuka tampilan list tiket secara real-time!
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
""" """
try: try:
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id) self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
@@ -1037,19 +1057,24 @@ class FreshserviceTicket(models.Model):
return action return action
@api.model @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): if name and isinstance(name, str):
ticket_ids = re.findall(r'\d+', name.strip()) ticket_ids = re.findall(r'\d+', name.strip())
for ticket_id in ticket_ids: for ticket_id in ticket_ids:
if not self.search_count([('external_ticket_id', '=', ticket_id)]): if not self.search_count([('external_ticket_id', '=', ticket_id)]):
self._fetch_and_create_ticket_by_external_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 @api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
""" """Override web_search_read: otomatis sync tiket terbaru dari Freshservice
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket. setiap kali user buka atau refresh halaman list tiket.
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings. 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'): if offset == 0 and not self.env.context.get('skip_auto_fetch'):
try: try:
@@ -1067,9 +1092,8 @@ class FreshserviceTicket(models.Model):
@api.readonly @api.readonly
def web_read(self, specification: dict[str, dict]) -> list[dict]: def web_read(self, specification: dict[str, dict]) -> list[dict]:
""" """Override web_read: saat user buka detail tiket (form view),
Ketika pengguna membuka form view (detail tiket), jika detail audit trail kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice.
belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand.
""" """
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'): 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 # Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
@@ -1090,8 +1114,8 @@ class FreshserviceTicket(models.Model):
return super().web_read(specification) return super().web_read(specification)
def action_sync_recent_tickets(self): def action_sync_recent_tickets(self):
""" """Tombol manual 'Tarik Tiket Terbaru' di halaman list.
Tombol manual 'Tarik Tiket Terbaru Freshservice' di header tabel jika user ingin refresh paksa. Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice.
""" """
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True) synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
return { return {

View File

@@ -1,9 +1,16 @@
# -*- coding: utf-8 -*- # -*- 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 import models, fields, api
from odoo.exceptions import UserError from odoo.exceptions import UserError
import requests import requests
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
Bisa diakses dari menu Settings > Freshservice.
"""
_inherit = 'res.config.settings' _inherit = 'res.config.settings'
freshservice_url = fields.Char( freshservice_url = fields.Char(
@@ -30,7 +37,9 @@ class ResConfigSettings(models.TransientModel):
) )
def action_test_freshservice_connection(self): 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() self.ensure_one()
url = (self.freshservice_url or "").rstrip("/") url = (self.freshservice_url or "").rstrip("/")
if not url: 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 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_user,freshservice.ticket.user,model_freshservice_ticket,gmn_freshservice.group_freshservice_user,1,0,0,0
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_manager,freshservice.ticket.manager,model_freshservice_ticket,gmn_freshservice.group_freshservice_manager,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_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_bottleneck_wizard_user,freshservice.ticket.bottleneck.wizard.user,model_freshservice_ticket_bottleneck_wizard,base.group_user,1,1,1,1 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 -*- # -*- coding: utf-8 -*-
"""Package services: berisi client untuk koneksi ke Freshservice API."""
from . import gmn_freshservice_client_v01 from . import gmn_freshservice_client_v01

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- 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 datetime
import logging import logging
import requests import requests
@@ -32,6 +35,11 @@ class FreshserviceClient:
_dept_cache = {} _dept_cache = {}
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False): 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.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or "" self.api_key = api_key or ""
self.use_mock = False self.use_mock = False
@@ -295,6 +303,13 @@ class FreshserviceClient:
# "conversations": conversations_json, # "conversations": conversations_json,
# } # }
def _get_live_payload(self, ticket_id): 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: if not self.base_url or not self.api_key:
raise UserError( raise UserError(
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!" "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

@@ -61,7 +61,7 @@
<field name="needs_verification"/> <field name="needs_verification"/>
<field name="classification_basis"/> <field name="classification_basis"/>
</group> </group>
<group> <group groups="gmn_freshservice.group_freshservice_manager">
<field name="override_responsibility"/> <field name="override_responsibility"/>
<field name="override_source"/> <field name="override_source"/>
<field name="override_reviewer"/> <field name="override_reviewer"/>

View File

@@ -11,7 +11,8 @@
type="object" type="object"
string="Sync Freshservice Data" string="Sync Freshservice Data"
class="btn-primary" class="btn-primary"
icon="fa-refresh"/> icon="fa-refresh"
groups="gmn_freshservice.group_freshservice_manager"/>
<button name="action_open_bottleneck_wizard" <button name="action_open_bottleneck_wizard"
type="object" type="object"
string="Bottleneck Report (Excel)" string="Bottleneck Report (Excel)"

View File

@@ -35,15 +35,31 @@ action = model.action_open_and_fetch_tickets()
action="action_freshservice_ticket_bottleneck_wizard" action="action_freshservice_ticket_bottleneck_wizard"
sequence="30"/> sequence="30"/>
<!-- CONFIGURATION MENU --> <!-- CONFIGURATION MENU (Manager Only) -->
<menuitem id="menu_freshservice_config_parent" <menuitem id="menu_freshservice_config_parent"
name="Konfigurasi" name="Konfigurasi"
parent="menu_freshservice_root" parent="menu_freshservice_root"
groups="gmn_freshservice.group_freshservice_manager"
sequence="90"/> sequence="90"/>
<menuitem id="menu_freshservice_config_settings" <menuitem id="menu_freshservice_config_settings"
name="Pengaturan Integrasi" name="Pengaturan Integrasi"
parent="menu_freshservice_config_parent" parent="menu_freshservice_config_parent"
action="action_freshservice_config_settings" action="action_freshservice_config_settings"
groups="gmn_freshservice.group_freshservice_manager"
sequence="10"/> 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> </odoo>

View File

@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*- # -*- 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_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 -*- # -*- 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 io
import re import re
import base64 import base64
@@ -11,7 +16,12 @@ from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class FreshserviceTicketBottleneckWizard(models.TransientModel): 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' _name = 'freshservice.ticket.bottleneck.wizard'
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)' _description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
@@ -44,9 +54,10 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
date_from = fields.Date(string='Dari Tanggal Dibuat') date_from = fields.Date(string='Dari Tanggal Dibuat')
date_to = fields.Date(string='Sampai Tanggal Dibuat') date_to = fields.Date(string='Sampai Tanggal Dibuat')
as_of = fields.Datetime( as_of = fields.Datetime(
string='Cut-off As-Of (UTC)', string='Batas Waktu Cut-Off (As-Of)',
help='Jika diisi, semua durasi stage log dipotong sampai timestamp ini. ' help='Batas waktu simulasi retrospektif (opsional). '
'Timestamp laporan ditampilkan dalam WIB (UTC+7).', 'Jika diisi, perhitungan durasi seluruh stage log dipotong sampai timestamp ini. '
'Kosongkan untuk perhitungan real-time hingga saat ini.',
) )
status_filter = fields.Selection([ status_filter = fields.Selection([
@@ -72,8 +83,22 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
file_name = fields.Char(string='Nama File', readonly=True) file_name = fields.Char(string='Nama File', readonly=True)
bottleneck_summary_html = fields.Html(string='Ringkasan Analisis Bottleneck', 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') @api.onchange('ticket_id')
def _onchange_ticket_id(self): 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: if self.ticket_id and self.ticket_id.external_ticket_id:
self.external_ticket_id = self.ticket_id.external_ticket_id self.external_ticket_id = self.ticket_id.external_ticket_id
@@ -86,11 +111,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
return clean return clean
def _effective_responsibility(self, log): def _effective_responsibility(self, log):
"""Return the reviewed classification when present, otherwise the automatic one.""" """Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review,
kalau belum pakai hasil deteksi otomatis.
"""
return log.override_responsibility or log.responsibility return log.override_responsibility or log.responsibility
def _clipped_stage_interval(self, log, report_as_of): def _clipped_stage_interval(self, log, report_as_of):
"""Return the reportable (start, end) interval, clipped at the report cut-off.""" """Potong interval stage log sesuai cut-off laporan.
Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu.
"""
start = log.start_date start = log.start_date
if not start: if not start:
return False, False return False, False
@@ -132,6 +161,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
target_ticket.action_sync_from_freshservice() target_ticket.action_sync_from_freshservice()
except Exception as e: except Exception as e:
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(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: if not target_ticket:
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992).")) raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
@@ -356,9 +390,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
# Header Title # Header Title
ws1.merge_range('B2:H2', f'TICKET ANALYSIS REPORT #{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( ws1.write(
'B3', '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 subtitle_fmt
) )
@@ -698,7 +737,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
return str(dt) return str(dt)
row2 = 4 row2 = 4
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1): 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)) resp = stage_resp_map.get(log.id, self._effective_responsibility(log))
if resp == 'internal': if resp == 'internal':
resp_label = 'Agent Active Work' resp_label = 'Agent Active Work'
@@ -738,9 +782,10 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None') 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' verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No'
ws2.write(row2, 1, idx2, td_center_fmt) effective_end = end if (self.as_of and end) else log.end_date
ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt) ws2.write(row2, 1, current_idx2, td_center_fmt)
ws2.write(row2, 3, _to_wib_str(log.end_date), 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, 4, log.stage_name or '-', td_fmt)
ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt) ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt)
ws2.write(row2, 6, resp_label, resp_fmt) ws2.write(row2, 6, resp_label, resp_fmt)
@@ -776,10 +821,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws3.write(3, col_idx, h, th_fmt) ws3.write(3, col_idx, h, th_fmt)
row3 = 4 row3 = 4
for idx3, conv in enumerate(t.conversation_ids.sorted('created_at'), start=1): 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)' 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) conv_wib_str = _to_wib_str(conv.created_at)
ws3.write(row3, 1, idx3, td_center_fmt) ws3.write(row3, 1, current_idx3, td_center_fmt)
ws3.write(row3, 2, conv_wib_str, 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, 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, 4, conv.user_name or '-', td_fmt)

View File

@@ -25,14 +25,14 @@
</group> </group>
<!-- MODE SINGLE TICKET --> <!-- MODE SINGLE TICKET -->
<!-- <group string="Parameter Tiket Tunggal" invisible="report_type != 'single'"> -->
<group>
<group> <group>
<group string="Identitas Tiket">
<field name="external_ticket_id" placeholder="Contoh: 992"/> <field name="external_ticket_id" placeholder="Contoh: 992"/>
<field name="ticket_id" options="{'no_create': True}" placeholder="Atau cari judul tiket di Odoo..."/> <field name="ticket_id" options="{'no_create': True}" placeholder="Atau cari judul tiket di Odoo..."/>
</group> </group>
<group invisible="True"> <group string="Simulasi Cut-Off &amp; Opsi">
<field name="auto_sync_detail"/> <field name="as_of" placeholder="Kosongkan jika real-time s/d saat ini"/>
<field invisible="True" name="auto_sync_detail"/>
</group> </group>
</group> </group>
@@ -44,6 +44,7 @@
</group> </group>
<group> <group>
<field name="status_filter"/> <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"/> <field name="auto_sync_detail"/>
</group> </group>
<group colspan="2"> <group colspan="2">