Compare commits

...

2 Commits

Author SHA1 Message Date
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
31 changed files with 996 additions and 399 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

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

@@ -3,6 +3,7 @@
Urutan import penting karena ticket bergantung pada stage_log dan conversation. 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

@@ -73,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)),
@@ -87,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
@@ -283,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'),

View File

@@ -1057,16 +1057,18 @@ 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, """Override name_search: kalau user ketik nomor tiket yang belum ada di Odoo,
otomatis fetch dari Freshservice dulu baru tampilkan. 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):

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,0,0,0
access_freshservice_ticket_conversation_manager,freshservice.ticket.conversation.manager,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_manager,1,0,0,0
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 0 0 0
7 access_freshservice_ticket_conversation_manager freshservice.ticket.conversation.manager model_freshservice_ticket_conversation gmn_freshservice.group_freshservice_manager 1 0 0 0
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

@@ -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,3 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Package wizard untuk laporan bottleneck tiket Freshservice.""" """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

@@ -54,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([
@@ -82,6 +83,19 @@ 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.""" """Kalau user pilih tiket dari dropdown, otomatis isi nomor ID Freshservice-nya."""
@@ -376,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
) )
@@ -718,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'
@@ -758,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)
@@ -796,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">