Compare commits
6 Commits
a44219a8f2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c6b39444 | ||
|
|
24c7ea9447 | ||
|
|
dd44452fa1 | ||
|
|
c0a185478b | ||
|
|
62e545276f | ||
|
|
ef5ba7f159 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docs
|
||||||
|
gmn_freshservice/tests
|
||||||
126
README.md
Normal file
126
README.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
276
gmn_freshservice/models/gmn_freshservice_holiday_v01.py
Normal file
276
gmn_freshservice/models/gmn_freshservice_holiday_v01.py
Normal 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),
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -25,12 +31,16 @@ class FreshserviceTicketConversation(models.Model):
|
|||||||
|
|
||||||
sender_type = fields.Selection([
|
sender_type = fields.Selection([
|
||||||
('agent', 'IT Support (Agent)'),
|
('agent', 'IT Support (Agent)'),
|
||||||
('customer', 'User (Customer)'),
|
('customer', 'User (Requester)'),
|
||||||
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
|
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
|
||||||
|
|
||||||
body_text = fields.Text(string='Isi Pesan')
|
body_text = fields.Text(string='Isi Pesan')
|
||||||
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'
|
||||||
|
|||||||
@@ -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,20 +73,51 @@ _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).
|
||||||
try:
|
2. Fallback ke konstanta `_INDONESIA_HOLIDAYS` jika tabel master data di DB masih kosong
|
||||||
leaves = env['resource.calendar.leaves'].sudo().search([
|
atau jika env Odoo tidak tersedia (misal unit test standalone).
|
||||||
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
|
3. Menggabungkan dengan cuti dinamis dari `resource.calendar.leaves` jika tersedia.
|
||||||
('date_to', '>=', datetime.combine(start_date, time.min)),
|
"""
|
||||||
])
|
res = set()
|
||||||
for leave in leaves:
|
used_db = False
|
||||||
if leave.date_from:
|
|
||||||
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
|
if env and hasattr(env, '__getitem__'):
|
||||||
except Exception:
|
try:
|
||||||
pass
|
HolidayModel = env['freshservice.holiday'].sudo()
|
||||||
|
# Cek apakah ada record hari libur aktif di database
|
||||||
|
db_records = HolidayModel.search([
|
||||||
|
('date', '>=', start_date),
|
||||||
|
('date', '<=', end_date),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
if db_records:
|
||||||
|
res.update(rec.date for rec in db_records)
|
||||||
|
used_db = True
|
||||||
|
elif HolidayModel.search_count([('active', '=', True)]) > 0:
|
||||||
|
# Database sudah memiliki data libur, namun tidak ada libur di rentang tanggal ini
|
||||||
|
used_db = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not used_db:
|
||||||
|
# Fallback ke konstanta _INDONESIA_HOLIDAYS jika DB belum terisi / tanpa koneksi DB
|
||||||
|
res.update(d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date)
|
||||||
|
|
||||||
|
if env and hasattr(env, '__getitem__'):
|
||||||
|
try:
|
||||||
|
leaves = env['resource.calendar.leaves'].sudo().search([
|
||||||
|
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
|
||||||
|
('date_to', '>=', datetime.combine(start_date, time.min)),
|
||||||
|
])
|
||||||
|
for leave in leaves:
|
||||||
|
if leave.date_from:
|
||||||
|
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return res
|
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'
|
||||||
@@ -255,15 +294,36 @@ class FreshserviceTicketStageLog(models.Model):
|
|||||||
stage_name = fields.Char(string='Status / Stage', required=True)
|
stage_name = fields.Char(string='Status / Stage', required=True)
|
||||||
|
|
||||||
responsibility = fields.Selection([
|
responsibility = fields.Selection([
|
||||||
('internal', 'Internal (Waiting on Us)'),
|
('internal', 'Agent Active Work'),
|
||||||
('customer', 'Customer (Waiting on User)'),
|
('it_hold', 'Agent Hold & Discussion'),
|
||||||
('on_hold', 'On Hold (SLA is Paused)'),
|
('customer', 'Waiting on Requester'),
|
||||||
('third_party', 'Waiting on Third Party (SLA is Paused)'),
|
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
|
||||||
('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'),
|
('needs_verification', 'Waiting on User Verification'),
|
||||||
('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'),
|
('scheduled_meeting', 'Scheduled Meeting (Shared)'),
|
||||||
('external_activity', 'External Activity (SLA is Paused)'),
|
('third_party', 'Waiting on Third Party'),
|
||||||
('resolved_idle', 'Resolved (Ticket Closed/Idle)'),
|
('resolved_idle', 'Resolved / Idle'),
|
||||||
], string='Tanggung Jawab', required=True, default='internal', index=True)
|
# Legacy values are retained for historical audit snapshots.
|
||||||
|
('on_hold', 'On Hold (Legacy)'),
|
||||||
|
('internal_discussion', 'Internal Discussion (Legacy)'),
|
||||||
|
('external_activity', 'External Activity (Legacy)'),
|
||||||
|
], string='Task Owner', required=True, default='internal', index=True)
|
||||||
|
|
||||||
|
effective_status = fields.Char(string='Effective Status', index=True)
|
||||||
|
classification_basis = fields.Char(string='Classification Basis')
|
||||||
|
needs_verification = fields.Boolean(string='User Verification Required')
|
||||||
|
delay_cause = fields.Selection([
|
||||||
|
('it', 'Agent'), ('user', 'User'), ('none', 'None'),
|
||||||
|
], string='Delay Cause', default='none')
|
||||||
|
automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True)
|
||||||
|
override_responsibility = fields.Selection([
|
||||||
|
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
|
||||||
|
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
|
||||||
|
('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
|
||||||
|
('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'),
|
||||||
|
], string='Task Owner Override')
|
||||||
|
override_source = fields.Char(string='Override Source')
|
||||||
|
override_reviewer = fields.Char(string='Override Reviewer')
|
||||||
|
override_at = fields.Datetime(string='Override Date')
|
||||||
|
|
||||||
actor_name = fields.Char(string='Pelaku / Actor')
|
actor_name = fields.Char(string='Pelaku / Actor')
|
||||||
actor_type = fields.Selection([
|
actor_type = fields.Selection([
|
||||||
@@ -324,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)
|
||||||
@@ -342,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)
|
||||||
|
|||||||
@@ -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']
|
||||||
@@ -36,7 +44,7 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
requester_name = fields.Char(string='Pemohon (User)', tracking=True)
|
requester_name = fields.Char(string='Pemohon (User)', tracking=True)
|
||||||
requester_email = fields.Char(string='Email Pemohon')
|
requester_email = fields.Char(string='Email Pemohon')
|
||||||
responder_name = fields.Char(string='Agen Penanggung Jawab (IT)', tracking=True)
|
responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True)
|
||||||
department_name = fields.Char(string='Departemen')
|
department_name = fields.Char(string='Departemen')
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -62,9 +70,13 @@ class FreshserviceTicket(models.Model):
|
|||||||
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
|
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
|
||||||
due_by = fields.Datetime(string='Batas Waktu (Due Date)')
|
due_by = fields.Datetime(string='Batas Waktu (Due Date)')
|
||||||
first_responded_at = fields.Datetime(string='Waktu First Response')
|
first_responded_at = fields.Datetime(string='Waktu First Response')
|
||||||
|
first_ack_at = fields.Datetime(string='Waktu First Acknowledgement', readonly=True)
|
||||||
|
first_meaningful_response_at = fields.Datetime(
|
||||||
|
string='Waktu First Meaningful Response', readonly=True,
|
||||||
|
)
|
||||||
resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)')
|
resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)')
|
||||||
|
|
||||||
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, readonly=True)
|
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True)
|
||||||
is_detail_synced = fields.Boolean(
|
is_detail_synced = fields.Boolean(
|
||||||
string='Detail Lengkap Tersinkron',
|
string='Detail Lengkap Tersinkron',
|
||||||
default=False,
|
default=False,
|
||||||
@@ -112,24 +124,58 @@ class FreshserviceTicket(models.Model):
|
|||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
total_agent_time_display = fields.Char(
|
total_agent_time_display = fields.Char(
|
||||||
string='Agen (Waiting on Us)',
|
string='Agent Responsibility',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
|
|
||||||
total_customer_time_days = fields.Integer(
|
total_customer_time_days = fields.Integer(
|
||||||
string='Total Waktu di User (Hari)',
|
string='Requester Time (Days)',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
total_customer_time_hours = fields.Float(
|
total_customer_time_hours = fields.Float(
|
||||||
string='Total Waktu di User (Jam)',
|
string='Requester Time (Hours)',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True,
|
store=True,
|
||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
total_customer_time_display = fields.Char(
|
total_customer_time_display = fields.Char(
|
||||||
string='User (Waiting on User)',
|
string='Requester Responsibility',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_wait_time_days = fields.Integer(
|
||||||
|
string='Scheduled Meeting (Days)',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
shared_wait_time_hours = fields.Float(
|
||||||
|
string='Scheduled Meeting (Hours)',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True,
|
||||||
|
digits=(12, 2)
|
||||||
|
)
|
||||||
|
shared_wait_time_display = fields.Char(
|
||||||
|
string='Scheduled Meeting',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
third_party_time_days = fields.Integer(
|
||||||
|
string='Waiting on Third Party (Hari)',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
third_party_time_hours = fields.Float(
|
||||||
|
string='Waiting on Third Party (Jam)',
|
||||||
|
compute='_compute_time_metrics',
|
||||||
|
store=True,
|
||||||
|
digits=(12, 2)
|
||||||
|
)
|
||||||
|
third_party_time_display = fields.Char(
|
||||||
|
string='Waiting on Third Party',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
@@ -146,16 +192,16 @@ class FreshserviceTicket(models.Model):
|
|||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
resolution_time_display = fields.Char(
|
resolution_time_display = fields.Char(
|
||||||
string='Total Waktu Selesai',
|
string='Total Resolution Time',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
|
|
||||||
current_responsibility = fields.Selection([
|
current_responsibility = fields.Selection([
|
||||||
('internal', 'Waiting on Us (Internal)'),
|
('internal', 'Agent Active Work'),
|
||||||
('customer', 'Waiting on User (Customer)'),
|
('customer', 'Waiting on Requester'),
|
||||||
('closed', 'Tiket Selesai'),
|
('closed', 'Completed'),
|
||||||
], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True)
|
], string='Current Responsibility', compute='_compute_time_metrics', store=True)
|
||||||
|
|
||||||
# Relasi
|
# Relasi
|
||||||
stage_log_ids = fields.One2many(
|
stage_log_ids = fields.One2many(
|
||||||
@@ -171,9 +217,15 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced',
|
'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced',
|
||||||
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.is_active'
|
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active'
|
||||||
)
|
)
|
||||||
def _compute_time_metrics(self):
|
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:
|
||||||
@@ -190,14 +242,24 @@ class FreshserviceTicket(models.Model):
|
|||||||
rec.first_response_time_display = "Belum Ada Respon"
|
rec.first_response_time_display = "Belum Ada Respon"
|
||||||
|
|
||||||
# 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender.
|
# 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender.
|
||||||
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'internal')
|
# Mengikuti Aturan Fair Responsibility:
|
||||||
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer')
|
# - IT Aktif / IT Hold / On Hold / Internal Discussion: Agen (Waiting on Us)
|
||||||
|
# - User / User Setelah Auto Resolve: Customer (Waiting on User)
|
||||||
|
# - Perlu Verifikasi: Dicatat tersendiri / dapat masuk rentang estimasi
|
||||||
|
# - Scheduled Meeting: Bersama
|
||||||
|
# - Third Party: Pihak Ketiga
|
||||||
|
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('internal', 'it_hold', 'on_hold', 'internal_discussion'))
|
||||||
|
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('customer', 'user_after_auto_resolve', 'needs_verification'))
|
||||||
|
shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'scheduled_meeting')
|
||||||
|
third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('third_party', 'external_activity'))
|
||||||
agent_days = round(agent_hours / 8.0, 2)
|
agent_days = round(agent_hours / 8.0, 2)
|
||||||
cust_days = round(cust_hours / 8.0, 2)
|
cust_days = round(cust_hours / 8.0, 2)
|
||||||
|
shared_wait_days = round(shared_wait_hours / 8.0, 2)
|
||||||
|
third_party_days = round(third_party_hours / 8.0, 2)
|
||||||
|
|
||||||
# Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT.
|
# Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT.
|
||||||
total_working_hours = sum(l.working_hours for l in rec.stage_log_ids)
|
total_working_hours = sum(l.working_hours for l in rec.stage_log_ids)
|
||||||
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'resolved_idle')
|
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'resolved_idle')
|
||||||
|
|
||||||
rec.total_agent_time_hours = round(agent_hours, 2)
|
rec.total_agent_time_hours = round(agent_hours, 2)
|
||||||
rec.total_agent_time_days = round(agent_days)
|
rec.total_agent_time_days = round(agent_days)
|
||||||
@@ -207,6 +269,14 @@ class FreshserviceTicket(models.Model):
|
|||||||
rec.total_customer_time_days = round(cust_days)
|
rec.total_customer_time_days = round(cust_days)
|
||||||
rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
|
rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
|
||||||
|
|
||||||
|
rec.shared_wait_time_hours = round(shared_wait_hours, 2)
|
||||||
|
rec.shared_wait_time_days = round(shared_wait_days)
|
||||||
|
rec.shared_wait_time_display = format_duration_days(round(shared_wait_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
|
||||||
|
|
||||||
|
rec.third_party_time_hours = round(third_party_hours, 2)
|
||||||
|
rec.third_party_time_days = round(third_party_days)
|
||||||
|
rec.third_party_time_display = format_duration_days(round(third_party_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
|
||||||
|
|
||||||
# 3. Hitung Resolution Time
|
# 3. Hitung Resolution Time
|
||||||
if rec.ticket_created_at and rec.resolved_at:
|
if rec.ticket_created_at and rec.resolved_at:
|
||||||
delta_res = rec.resolved_at - rec.ticket_created_at
|
delta_res = rec.resolved_at - rec.ticket_created_at
|
||||||
@@ -246,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}
|
||||||
@@ -269,11 +341,35 @@ class FreshserviceTicket(models.Model):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def action_sync_from_freshservice(self):
|
def _ball_in_court_check(self, effective_status, last_msg, last_actor_type):
|
||||||
"""
|
"""
|
||||||
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
|
Aturan B: Ball-in-court analysis.
|
||||||
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan.
|
Mengevaluasi apakah stage hold/waiting memiliki potensi misklasifikasi
|
||||||
Mendukung concurrency row-lock untuk keamanan multi-user.
|
karena Agent sedang menunggu konfirmasi user atau sebaliknya.
|
||||||
|
Mengembalikan (is_needs_verification, basis_snippet).
|
||||||
|
"""
|
||||||
|
if not last_msg:
|
||||||
|
return False, ""
|
||||||
|
msg_lower = last_msg.lower()
|
||||||
|
confirmation_keywords = [
|
||||||
|
'mohon konfirmasi', 'konfirmasi', 'mohon dicek', 'silakan dicek',
|
||||||
|
'apakah sudah', 'apakah bisa', 'menunggu konfirmasi', 'apakah kendala',
|
||||||
|
'mohon info', 'tolong konfirmasi', 'konfirmasinya'
|
||||||
|
]
|
||||||
|
has_req = any(kw in msg_lower for kw in confirmation_keywords) or ('?' in last_msg and last_actor_type == 'Agent')
|
||||||
|
if has_req and (effective_status in ('Waiting on Internal Discussion', 'On Hold') or 'hold' in str(effective_status).lower()):
|
||||||
|
words = last_msg.strip().split()
|
||||||
|
snippet = ' '.join(words[:10]) + ('...' if len(words) > 10 else '')
|
||||||
|
return True, f"Agent meminta konfirmasi: \"{snippet}\""
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
def action_sync_from_freshservice(self):
|
||||||
|
"""Tarik data lengkap tiket dari Freshservice API.
|
||||||
|
Proses:
|
||||||
|
1. Ambil detail tiket (subject, status, requester, dll)
|
||||||
|
2. Ambil audit trail activities lalu rekonstruksi jadi stage log
|
||||||
|
3. Ambil percakapan (reply/note)
|
||||||
|
Pakai row-lock supaya aman kalau 2 user sync bersamaan.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
@@ -289,9 +385,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
get_param = self.env['ir.config_parameter'].sudo().get_param
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
base_url = get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id')
|
base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
|
||||||
api_key = get_param('freshservice_ticket_audit.freshservice_api_key', '')
|
api_key = get_param('gmn_freshservice.freshservice_api_key', '')
|
||||||
use_mock = get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
|
use_mock = False
|
||||||
|
|
||||||
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
|
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
|
||||||
payload = client.fetch_ticket_full_data(self.external_ticket_id)
|
payload = client.fetch_ticket_full_data(self.external_ticket_id)
|
||||||
@@ -389,7 +485,13 @@ class FreshserviceTicket(models.Model):
|
|||||||
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
|
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
|
||||||
|
|
||||||
log_vals_list = []
|
log_vals_list = []
|
||||||
|
# Hanya perubahan status eksplisit yang mengubah state berikutnya.
|
||||||
|
effective_status = 'Open'
|
||||||
current_state = 'internal'
|
current_state = 'internal'
|
||||||
|
last_public_message = False
|
||||||
|
last_message_actor_type = False
|
||||||
|
first_ack_dt = False
|
||||||
|
first_meaningful_dt = False
|
||||||
meeting_wait_until = False
|
meeting_wait_until = False
|
||||||
meeting_start_dt = False
|
meeting_start_dt = False
|
||||||
meeting_waiting_for_followup = False
|
meeting_waiting_for_followup = False
|
||||||
@@ -504,59 +606,128 @@ class FreshserviceTicket(models.Model):
|
|||||||
else:
|
else:
|
||||||
stage_name = 'Aktivitas'
|
stage_name = 'Aktivitas'
|
||||||
|
|
||||||
# 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine.
|
# 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification
|
||||||
# Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state.
|
# ==============================================================================
|
||||||
|
# Aturan A: Hanya event "set Status as X" yang mengubah effective_status.
|
||||||
|
# Event non-status (Timesheet, Note, Assignment, Reply) MEWARISI status sebelumnya.
|
||||||
|
# Aturan B: Ball-in-court check untuk IT_HOLD / Waiting on End User.
|
||||||
|
# Deteksi 2 segmen PERLU_VERIFIKASI (Sep 18 09:38-Sep 21 14:05 & Oct 6 09:28-Oct 9 10:44).
|
||||||
|
# Aturan C: Resolved oleh sistem/auto-rule → USER_SETELAH_AUTO_RESOLVE.
|
||||||
|
# Aturan D: Scheduled Meeting → BERSAMA dengan penanda delay_cause.
|
||||||
|
# Aturan E: First response substantif terpisah dari auto-ack.
|
||||||
|
# ==============================================================================
|
||||||
combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower()
|
combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower()
|
||||||
is_timesheet = any(k in combined_text for k in ('timesheet', 'time spent', 'create timesheet'))
|
status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower()
|
||||||
|
explicit_status = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I))
|
||||||
|
vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text))
|
||||||
|
is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I))
|
||||||
|
|
||||||
if is_timesheet:
|
# Update pesan terakhir jika ada balasan/catatan publik
|
||||||
stage_name = 'Create Timesheet'
|
if matched_msg:
|
||||||
responsibility = 'external_activity'
|
last_public_message = matched_msg
|
||||||
else:
|
last_message_actor_type = actor_type
|
||||||
status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower()
|
elif 'replied' in content_main.lower() or 'reply' in content_main.lower():
|
||||||
explicit_status = 'set status as' in status_text
|
last_public_message = content_main
|
||||||
is_requester_reopen_workflow = (
|
last_message_actor_type = actor_type
|
||||||
actor_type == 'System'
|
|
||||||
and 'reopen tickets when the requester responds' in status_text
|
# Rekam First Response (Aturan E)
|
||||||
)
|
if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()):
|
||||||
requester_email = (self.requester_email or '').strip().lower()
|
is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting'))
|
||||||
is_agent_reply_to_requester = (
|
if not first_ack_dt:
|
||||||
actor_type.strip().lower() == 'agent'
|
first_ack_dt = start_dt_obj
|
||||||
and bool(re.search(r'replied\s+to', status_text, re.I))
|
if not is_auto_ack and not first_meaningful_dt:
|
||||||
)
|
first_meaningful_dt = start_dt_obj
|
||||||
is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I))
|
|
||||||
vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text))
|
classification_basis = "Status warisan: %s" % effective_status
|
||||||
confirmation_to_user = bool(re.search(r'(mohon|please|harap|kindly).{0,100}(konfirmasi|confirmation|keputusan|decision|approve|persetujuan)', status_text, re.I))
|
needs_verification_flag = False
|
||||||
if is_agent_assignment:
|
delay_cause = 'none'
|
||||||
# Penugasan/perubahan agent mengalihkan tanggung jawab kembali ke internal (Waiting on Us).
|
|
||||||
current_state = 'internal'
|
# Update effective_status hanya jika ada perubahan eksplisit
|
||||||
elif vendor_external or (explicit_status and 'waiting on third party' in status_text):
|
if vendor_external or (explicit_status and 'waiting on third party' in status_text):
|
||||||
# Vendor Escalation=External adalah trigger resmi meski bukan field Status.
|
effective_status = 'Waiting on Third Party'
|
||||||
current_state = 'third_party'
|
current_state = 'third_party'
|
||||||
elif explicit_status and 'waiting on internal discussion' in status_text:
|
classification_basis = 'Status Freshservice: Waiting on Third Party'
|
||||||
current_state = 'internal_discussion'
|
elif explicit_status and 'waiting on internal discussion' in status_text:
|
||||||
elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')):
|
effective_status = 'Waiting on Internal Discussion'
|
||||||
current_state = 'customer'
|
current_state = 'it_hold'
|
||||||
elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')):
|
classification_basis = 'Status Freshservice: Waiting on Internal Discussion'
|
||||||
current_state = 'on_hold'
|
elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')):
|
||||||
elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')):
|
effective_status = 'Waiting on End User'
|
||||||
|
current_state = 'customer'
|
||||||
|
classification_basis = 'Status Freshservice: Waiting on End User'
|
||||||
|
elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')):
|
||||||
|
effective_status = 'On Hold'
|
||||||
|
current_state = 'it_hold'
|
||||||
|
classification_basis = 'Status Freshservice: On Hold'
|
||||||
|
elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')):
|
||||||
|
is_auto_resolve = actor_type == 'System' or 'auto-resolve' in combined_text or 'workflow' in combined_text
|
||||||
|
effective_status = 'Resolved'
|
||||||
|
if is_auto_resolve:
|
||||||
|
current_state = 'user_after_auto_resolve'
|
||||||
|
classification_basis = 'Auto-resolve oleh sistem (menunggu respon user)'
|
||||||
|
else:
|
||||||
current_state = 'resolved_idle'
|
current_state = 'resolved_idle'
|
||||||
elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')):
|
classification_basis = 'Status Freshservice: Resolved'
|
||||||
current_state = 'internal'
|
elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')):
|
||||||
elif is_agent_reply_to_requester:
|
effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open'
|
||||||
# Agent sudah membalas requester; giliran berikutnya
|
current_state = 'internal'
|
||||||
# menunggu respons user dan dibebankan ke end user (customer).
|
classification_basis = 'Status Freshservice: %s' % effective_status
|
||||||
current_state = 'customer'
|
|
||||||
# Balasan requester tidak serta-merta mengalihkan pembebanan ke internal
|
|
||||||
# sebelum tiket resmi di-reopen oleh workflow atau agent.
|
|
||||||
elif actor_type == 'Agent' and current_state in ('on_hold', 'internal_discussion') and confirmation_to_user:
|
|
||||||
# Pengecualian: note Agent yang eksplisit meminta keputusan/konfirmasi user.
|
|
||||||
current_state = 'customer'
|
|
||||||
|
|
||||||
responsibility = current_state
|
responsibility = current_state
|
||||||
|
|
||||||
# 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent.
|
# Aturan B: status hold tidak otomatis berarti IT menunggu internal.
|
||||||
|
# Bila pesan publik terakhir dari Agent meminta konfirmasi user, klasifikasi
|
||||||
|
# dibuat eksplisit sebagai perlu verifikasi dan menyimpan kutipan pemicunya.
|
||||||
|
is_ball_req, basis_ball = self._ball_in_court_check(
|
||||||
|
effective_status, last_public_message, last_message_actor_type,
|
||||||
|
)
|
||||||
|
if is_ball_req:
|
||||||
|
responsibility = 'needs_verification'
|
||||||
|
needs_verification_flag = True
|
||||||
|
classification_basis = basis_ball
|
||||||
|
|
||||||
|
# Penanganan khusus Create Timesheet: MEWARISI status efektif (Aturan A)
|
||||||
|
if 'timesheet' in combined_text:
|
||||||
|
stage_name = 'Create Timesheet'
|
||||||
|
# Timesheet tidak mengubah responsibility, tetap memakai responsibility dari status efektif
|
||||||
|
classification_basis = 'Timesheet (mewarisi status efektif %s)' % effective_status
|
||||||
|
|
||||||
|
# Penanganan balasan Agent: tidak membalik tanggung jawab jika status masih di user (Aturan A)
|
||||||
|
if actor_type == 'Agent' and any(k in content_main.lower() for k in ('replied', 'reply')) and current_state == 'customer':
|
||||||
|
responsibility = 'customer'
|
||||||
|
classification_basis = 'Balasan Agent (mewarisi status Waiting on End User)'
|
||||||
|
|
||||||
|
# 4a. Smart Detection: Undangan Zoom / Meeting & Aturan Pembatalan Meeting (Cancel Meeting)
|
||||||
is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or ''))
|
is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or ''))
|
||||||
|
is_meeting_cancelled = False
|
||||||
|
|
||||||
|
# Cek apakah setelah link meeting dikirim, meeting dibatalkan atau digantikan oleh status resmi:
|
||||||
|
# 1. Perubahan status resmi selain "Waiting on Internal Discussion" (misal: In Progress, Waiting on End User, On Hold, Resolved).
|
||||||
|
# 2. Atau terdapat kata kunci pembatalan eksplisit (cancel/batal meeting/zoom/call).
|
||||||
|
if meeting_waiting_for_followup and not is_meeting_invitation:
|
||||||
|
has_explicit_status_change = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) or vendor_external or is_agent_assignment
|
||||||
|
is_internal_disc = 'waiting on internal discussion' in status_text.lower()
|
||||||
|
cancel_kw = bool(re.search(r'\b(batal|cancel|reschedule|tidak\s+jadi)\b.{0,30}\b(meeting|zoom|call|gmeet)\b', full_activity_content or '', re.I))
|
||||||
|
|
||||||
|
if has_explicit_status_change and not is_internal_disc:
|
||||||
|
# An explicit status transition ends the shared meeting phase,
|
||||||
|
# but it is not a cancellation by itself. The next status or
|
||||||
|
# activity is allowed to determine the normal responsibility.
|
||||||
|
meeting_waiting_for_followup = False
|
||||||
|
meeting_start_dt = False
|
||||||
|
meeting_wait_until = False
|
||||||
|
interval_meeting_start = False
|
||||||
|
|
||||||
|
if cancel_kw:
|
||||||
|
# Only explicit cancellation wording marks the phase as
|
||||||
|
# cancelled in the audit trail.
|
||||||
|
is_meeting_cancelled = True
|
||||||
|
stage_name = f'Meeting Cancelled / {stage_name}'
|
||||||
|
meeting_waiting_for_followup = False
|
||||||
|
meeting_start_dt = False
|
||||||
|
meeting_wait_until = False
|
||||||
|
interval_meeting_start = False
|
||||||
|
|
||||||
if is_meeting_invitation:
|
if is_meeting_invitation:
|
||||||
parsed_meeting_dt = False
|
parsed_meeting_dt = False
|
||||||
time_match = meeting_time_pattern.search(full_activity_content or '')
|
time_match = meeting_time_pattern.search(full_activity_content or '')
|
||||||
@@ -571,6 +742,10 @@ class FreshserviceTicket(models.Model):
|
|||||||
meeting_start_dt = parsed_meeting_dt
|
meeting_start_dt = parsed_meeting_dt
|
||||||
meeting_waiting_for_followup = True
|
meeting_waiting_for_followup = True
|
||||||
meeting_wait_until = False
|
meeting_wait_until = False
|
||||||
|
# Aktivitas pengiriman link dibebankan kepada pengirimnya.
|
||||||
|
# Jika Agent yang mengirim, stage log ini berwarna Internal (merah)
|
||||||
|
# di Excel. Fase Waiting Together baru dimulai setelah aktivitas ini.
|
||||||
|
responsibility = 'internal' if actor_type == 'Agent' else current_state
|
||||||
|
|
||||||
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
|
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
|
||||||
meeting_wait_until = False
|
meeting_wait_until = False
|
||||||
@@ -581,22 +756,27 @@ class FreshserviceTicket(models.Model):
|
|||||||
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
|
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
|
||||||
)
|
)
|
||||||
if is_meeting_invitation:
|
if is_meeting_invitation:
|
||||||
responsibility = 'scheduled_meeting'
|
# Pengiriman link adalah pekerjaan pembuat link. Jangan mengubahnya
|
||||||
|
# menjadi Waiting Together; status meeting dipakai aktivitas berikutnya.
|
||||||
|
responsibility = 'internal' if actor_type == 'Agent' else current_state
|
||||||
meeting_wait_until = False
|
meeting_wait_until = False
|
||||||
elif meeting_waiting_for_followup and start_dt_obj:
|
elif is_meeting_cancelled:
|
||||||
# No parsable Time: waits together until Agent follow-up. With Time:,
|
# Tanggung jawab kembali ke status operasional saat ini (current_state)
|
||||||
# the period from meeting start until that same follow-up is internal.
|
responsibility = current_state
|
||||||
if meeting_start_dt and start_dt_obj >= meeting_start_dt:
|
elif (
|
||||||
responsibility = 'internal'
|
meeting_waiting_for_followup
|
||||||
if is_agent_followup:
|
and not is_meeting_invitation
|
||||||
meeting_waiting_for_followup = False
|
and 'waiting on internal discussion' in status_text.lower()
|
||||||
meeting_start_dt = False
|
):
|
||||||
meeting_wait_until = start_dt_obj
|
# Setelah link meeting dikirim, status ini masih merupakan
|
||||||
else:
|
# fase Scheduled Meeting dan tidak membatalkan state meeting.
|
||||||
responsibility = 'scheduled_meeting'
|
responsibility = 'scheduled_meeting'
|
||||||
if not meeting_start_dt and is_agent_followup:
|
elif meeting_waiting_for_followup:
|
||||||
meeting_waiting_for_followup = False
|
# Keep the complete interval shared while the ticket remains in
|
||||||
meeting_wait_until = start_dt_obj
|
# Internal Discussion or Scheduled Meeting. Do not split at the
|
||||||
|
# scheduled meeting start; the next explicit status/activity
|
||||||
|
# determines the new responsibility.
|
||||||
|
responsibility = 'scheduled_meeting'
|
||||||
|
|
||||||
# 5. Set First Responder jika belum terdeteksi
|
# 5. Set First Responder jika belum terdeteksi
|
||||||
if not self.first_responder_name and actor_type == 'Agent':
|
if not self.first_responder_name and actor_type == 'Agent':
|
||||||
@@ -606,35 +786,34 @@ class FreshserviceTicket(models.Model):
|
|||||||
log_values = {
|
log_values = {
|
||||||
'stage_name': stage_name,
|
'stage_name': stage_name,
|
||||||
'responsibility': responsibility,
|
'responsibility': responsibility,
|
||||||
|
'effective_status': effective_status,
|
||||||
|
'classification_basis': classification_basis,
|
||||||
|
'needs_verification': needs_verification_flag,
|
||||||
|
'delay_cause': delay_cause,
|
||||||
'actor_name': actor_name,
|
'actor_name': actor_name,
|
||||||
'actor_type': actor_type,
|
'actor_type': actor_type,
|
||||||
'activity_content': full_activity_content or stage_name,
|
'activity_content': full_activity_content or stage_name,
|
||||||
'start_date': start_dt,
|
'start_date': start_dt,
|
||||||
'end_date': end_dt,
|
'end_date': end_dt,
|
||||||
}
|
}
|
||||||
# Split every affected interval at the scheduled start precisely:
|
# Keep one continuous shared-time interval while the effective status is
|
||||||
# invitation → meeting = shared SLA-paused wait; meeting → next activity = internal follow-up.
|
# Internal Discussion or Scheduled Meeting. The interval changes only
|
||||||
if interval_meeting_start and end_dt_obj and start_dt_obj < interval_meeting_start < end_dt_obj:
|
# when the next activity/status explicitly changes responsibility.
|
||||||
waiting_values = dict(
|
log_vals_list.append((0, 0, log_values))
|
||||||
log_values,
|
|
||||||
responsibility='scheduled_meeting',
|
ticket_write_vals = {}
|
||||||
end_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'),
|
if first_ack_dt:
|
||||||
)
|
ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
followup_values = dict(
|
if first_meaningful_dt:
|
||||||
log_values,
|
ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
stage_name='Scheduled Meeting / Follow-up',
|
|
||||||
responsibility='internal',
|
|
||||||
activity_content='%s\n[Auto split: meeting start → next Agent follow-up]' % (full_activity_content or stage_name),
|
|
||||||
start_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
)
|
|
||||||
log_vals_list.extend([(0, 0, waiting_values), (0, 0, followup_values)])
|
|
||||||
else:
|
|
||||||
log_vals_list.append((0, 0, log_values))
|
|
||||||
|
|
||||||
if log_vals_list:
|
if log_vals_list:
|
||||||
# Baru ganti snapshot setelah daftar audit trail pengganti siap.
|
# Baru ganti snapshot setelah daftar audit trail pengganti siap.
|
||||||
self.stage_log_ids.unlink()
|
self.stage_log_ids.unlink()
|
||||||
self.write({'stage_log_ids': log_vals_list})
|
ticket_write_vals['stage_log_ids'] = log_vals_list
|
||||||
|
self.write(ticket_write_vals)
|
||||||
|
elif ticket_write_vals:
|
||||||
|
self.write(ticket_write_vals)
|
||||||
|
|
||||||
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
|
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
|
||||||
conv_vals_list = []
|
conv_vals_list = []
|
||||||
@@ -657,7 +836,7 @@ class FreshserviceTicket(models.Model):
|
|||||||
'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
|
'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
mode_text = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "LIVE FRESHSERVICE API"
|
mode_text = "LIVE FRESHSERVICE API"
|
||||||
return {
|
return {
|
||||||
'type': 'ir.actions.client',
|
'type': 'ir.actions.client',
|
||||||
'tag': 'display_notification',
|
'tag': 'display_notification',
|
||||||
@@ -669,25 +848,6 @@ class FreshserviceTicket(models.Model):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def action_toggle_mock_mode(self):
|
|
||||||
"""Toggle instan mode antara Mock dan Live API"""
|
|
||||||
param_obj = self.env['ir.config_parameter'].sudo()
|
|
||||||
current = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
|
|
||||||
new_val = not current
|
|
||||||
param_obj.set_param('freshservice_ticket_audit.freshservice_use_mock', str(new_val))
|
|
||||||
|
|
||||||
status_label = "MOCK DATA (Simulasi)" if new_val else "LIVE API FRESHSERVICE"
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': 'Mode Berhasil Diubah',
|
|
||||||
'message': f'Sistem sekarang beralih ke: {status_label}. Klik tombol "Tarik Data Freshservice" untuk menyegarkan data.',
|
|
||||||
'type': 'info',
|
|
||||||
'sticky': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION
|
# AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -711,14 +871,14 @@ class FreshserviceTicket(models.Model):
|
|||||||
param_obj = self.env['ir.config_parameter'].sudo()
|
param_obj = self.env['ir.config_parameter'].sudo()
|
||||||
if not limit:
|
if not limit:
|
||||||
try:
|
try:
|
||||||
limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200)
|
limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
limit = 200
|
limit = 200
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
limit = 200
|
limit = 200
|
||||||
|
|
||||||
now_ts = fields.Datetime.now().timestamp()
|
now_ts = fields.Datetime.now().timestamp()
|
||||||
last_sync = param_obj.get_param('freshservice_ticket_audit.last_list_sync_time')
|
last_sync = param_obj.get_param('gmn_freshservice.last_list_sync_time')
|
||||||
if last_sync:
|
if last_sync:
|
||||||
try:
|
try:
|
||||||
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
|
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
|
||||||
@@ -726,14 +886,14 @@ class FreshserviceTicket(models.Model):
|
|||||||
return 0
|
return 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
param_obj.set_param('freshservice_ticket_audit.last_list_sync_time', str(now_ts))
|
param_obj.set_param('gmn_freshservice.last_list_sync_time', str(now_ts))
|
||||||
|
|
||||||
base_url = param_obj.get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id')
|
base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
|
||||||
api_key = param_obj.get_param('freshservice_ticket_audit.freshservice_api_key', '')
|
api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '')
|
||||||
use_mock = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
|
use_mock = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
|
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=False)
|
||||||
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
|
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
|
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
|
||||||
@@ -817,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
|
||||||
@@ -840,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()
|
||||||
|
|
||||||
@@ -874,20 +1038,18 @@ 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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning("Auto fetch saat buka menu tiket: %s", e)
|
_logger.warning("Auto fetch saat buka menu tiket: %s", e)
|
||||||
|
|
||||||
action = self.env['ir.actions.act_window']._for_xml_id('freshservice_ticket_audit.action_freshservice_ticket')
|
action = self.env['ir.actions.act_window']._for_xml_id('gmn_freshservice.action_freshservice_ticket')
|
||||||
param_obj = self.env['ir.config_parameter'].sudo()
|
param_obj = self.env['ir.config_parameter'].sudo()
|
||||||
try:
|
try:
|
||||||
sync_limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200)
|
sync_limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
|
||||||
if sync_limit > 0:
|
if sync_limit > 0:
|
||||||
action['limit'] = sync_limit
|
action['limit'] = sync_limit
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -895,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:
|
||||||
@@ -925,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
|
||||||
@@ -948,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 {
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
# -*- 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(
|
||||||
string='Freshservice URL',
|
string='Freshservice URL',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_url',
|
config_parameter='gmn_freshservice.freshservice_url',
|
||||||
default='https://ptglobalmultimedianetwork.freshservice.com'
|
default='https://ptglobalmultimedianetwork.freshservice.com'
|
||||||
)
|
)
|
||||||
freshservice_api_key = fields.Char(
|
freshservice_api_key = fields.Char(
|
||||||
string='Freshservice API Key',
|
string='Freshservice API Key',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_api_key'
|
config_parameter='gmn_freshservice.freshservice_api_key'
|
||||||
)
|
)
|
||||||
freshservice_sync_limit = fields.Integer(
|
freshservice_sync_limit = fields.Integer(
|
||||||
string='Limit Sinkronisasi Tiket',
|
string='Limit Sinkronisasi Tiket',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_sync_limit',
|
config_parameter='gmn_freshservice.freshservice_sync_limit',
|
||||||
default=200,
|
default=200,
|
||||||
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
|
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
|
||||||
)
|
)
|
||||||
freshservice_use_mock = fields.Boolean(
|
freshservice_use_mock = fields.Boolean(
|
||||||
string='Gunakan Mock Data Engine',
|
string='Gunakan Mock Data Engine',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_use_mock',
|
config_parameter='gmn_freshservice.freshservice_use_mock',
|
||||||
# default=True,
|
default=False,
|
||||||
help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.'
|
readonly=True,
|
||||||
|
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
55
gmn_freshservice/security/gmn_freshservice_security_v01.xml
Normal file
55
gmn_freshservice/security/gmn_freshservice_security_v01.xml
Normal 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 & 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>
|
||||||
@@ -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,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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -31,10 +34,15 @@ class FreshserviceClient:
|
|||||||
_agent_cache = {}
|
_agent_cache = {}
|
||||||
_dept_cache = {}
|
_dept_cache = {}
|
||||||
|
|
||||||
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=True):
|
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
|
||||||
|
"""Inisialisasi koneksi ke Freshservice.
|
||||||
|
- base_url: URL instance Freshservice (misal: https://company.freshservice.com)
|
||||||
|
- api_key: token API untuk otentikasi
|
||||||
|
- use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet
|
||||||
|
"""
|
||||||
self.base_url = (base_url or "").rstrip("/")
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
self.api_key = api_key or ""
|
self.api_key = api_key or ""
|
||||||
self.use_mock = use_mock
|
self.use_mock = False
|
||||||
|
|
||||||
def get_agent_name(self, agent_id):
|
def get_agent_name(self, agent_id):
|
||||||
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||||
@@ -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!"
|
||||||
|
|||||||
3
gmn_freshservice/tests/__init__.py
Normal file
3
gmn_freshservice/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Package test untuk modul GMN Freshservice."""
|
||||||
|
from . import test_responsibility_rules
|
||||||
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal file
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal 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)
|
||||||
134
gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml
Normal file
134
gmn_freshservice/views/gmn_freshservice_holiday_views_v01.xml
Normal 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 & 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 & 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 & 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 & 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 & 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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<field name="name">freshservice.ticket.stage.log.tree</field>
|
<field name="name">freshservice.ticket.stage.log.tree</field>
|
||||||
<field name="model">freshservice.ticket.stage.log</field>
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Audit Trail & Durasi Status"
|
<list string="Audit Trail & Status Duration"
|
||||||
decoration-danger="responsibility == 'internal' and is_active"
|
decoration-danger="responsibility == 'internal' and is_active"
|
||||||
decoration-warning="responsibility == 'customer' and is_active"
|
decoration-warning="responsibility == 'customer' and is_active"
|
||||||
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
<field name="stage_name" widget="badge"/>
|
<field name="stage_name" widget="badge"/>
|
||||||
<field name="responsibility"
|
<field name="responsibility"
|
||||||
widget="badge"
|
widget="badge"
|
||||||
decoration-danger="responsibility == 'internal'"
|
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
|
||||||
decoration-warning="responsibility == 'customer'"
|
decoration-warning="responsibility == 'customer'"
|
||||||
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
|
decoration-info="responsibility == 'scheduled_meeting'"
|
||||||
|
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
|
||||||
<field name="actor_name"/>
|
<field name="actor_name"/>
|
||||||
<field name="actor_type"/>
|
<field name="actor_type"/>
|
||||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
@@ -36,25 +37,37 @@
|
|||||||
<form string="Detail Audit Trail">
|
<form string="Detail Audit Trail">
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group string="Informasi Status & Tanggung Jawab">
|
||||||
<field name="ticket_id" readonly="1"/>
|
<field name="ticket_id" readonly="1"/>
|
||||||
<field name="stage_name" readonly="1"/>
|
<field name="stage_name" readonly="1"/>
|
||||||
<field name="responsibility" readonly="1"/>
|
<field name="effective_status" readonly="1"/>
|
||||||
|
<field name="responsibility" readonly="1" string="Effective Responsibility"/>
|
||||||
|
<field name="automatic_responsibility" readonly="1"/>
|
||||||
<field name="actor_name" readonly="1"/>
|
<field name="actor_name" readonly="1"/>
|
||||||
<field name="actor_type" readonly="1"/>
|
<field name="actor_type" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group string="Durasi Kerja Operasional (08:30-17:30 WIB)">
|
||||||
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
||||||
<field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/>
|
<field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/>
|
||||||
<field name="working_duration_display" readonly="1" string="Jam Kerja (08:30-17:30 WIB)"/>
|
<field name="working_duration_display" readonly="1" string="Jam Kerja Efektif"/>
|
||||||
<field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
|
<field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
|
||||||
<field name="duration_display" readonly="1" string="Durasi Kalender"/>
|
<field name="duration_display" readonly="1" string="Durasi Kalender"/>
|
||||||
<field name="duration_days" readonly="1"/>
|
|
||||||
<!-- Jam di-hide -->
|
|
||||||
<field name="duration_hours" invisible="1"/>
|
|
||||||
<field name="is_active" readonly="1"/>
|
<field name="is_active" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Klasifikasi & Manual Override (Aturan F)">
|
||||||
|
<group>
|
||||||
|
<field name="delay_cause"/>
|
||||||
|
<field name="needs_verification"/>
|
||||||
|
<field name="classification_basis"/>
|
||||||
|
</group>
|
||||||
|
<group groups="gmn_freshservice.group_freshservice_manager">
|
||||||
|
<field name="override_responsibility"/>
|
||||||
|
<field name="override_source"/>
|
||||||
|
<field name="override_reviewer"/>
|
||||||
|
<field name="override_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
<notebook>
|
<notebook>
|
||||||
<page string="Deskripsi Aktivitas">
|
<page string="Deskripsi Aktivitas">
|
||||||
<field name="activity_content" readonly="1"/>
|
<field name="activity_content" readonly="1"/>
|
||||||
@@ -70,20 +83,20 @@
|
|||||||
<field name="name">freshservice.ticket.stage.log.search</field>
|
<field name="name">freshservice.ticket.stage.log.search</field>
|
||||||
<field name="model">freshservice.ticket.stage.log</field>
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search string="Cari Log Aktivitas">
|
<search string="Search Activity Logs">
|
||||||
<field name="ticket_id"/>
|
<field name="ticket_id"/>
|
||||||
<field name="stage_name"/>
|
<field name="stage_name"/>
|
||||||
<field name="actor_name"/>
|
<field name="actor_name"/>
|
||||||
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
|
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
|
||||||
<filter name="filter_customer" string="Waiting on User (Customer)" domain="[('responsibility', '=', 'customer')]"/>
|
<filter name="filter_customer" string="Waiting on User (Requester)" domain="[('responsibility', '=', 'customer')]"/>
|
||||||
<filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/>
|
<filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/>
|
||||||
<filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/>
|
<filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/>
|
||||||
<filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/>
|
<filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/>
|
||||||
<filter name="filter_scheduled_meeting" string="Tunggu Bersama (Scheduled Meeting)" domain="[('responsibility', '=', 'scheduled_meeting')]"/>
|
<filter name="filter_scheduled_meeting" string="Scheduled Meeting (Waiting Together)" domain="[('responsibility', '=', 'scheduled_meeting')]"/>
|
||||||
<filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/>
|
<filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/>
|
||||||
<filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/>
|
<filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/>
|
||||||
<filter name="filter_sla_paused" string="Semua SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
|
<filter name="filter_sla_paused" string="All SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
|
||||||
<filter name="filter_active" string="Sedang Berjalan (Aktif)" domain="[('is_active', '=', True)]"/>
|
<filter name="filter_active" string="In Progress (Active)" domain="[('is_active', '=', True)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<group>
|
<group>
|
||||||
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
||||||
|
|||||||
@@ -9,32 +9,22 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_sync_from_freshservice"
|
<button name="action_sync_from_freshservice"
|
||||||
type="object"
|
type="object"
|
||||||
string="Tarik Data Freshservice"
|
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="Laporan Bottleneck (Excel)"
|
string="Bottleneck Report (Excel)"
|
||||||
class="btn-outline-success"
|
class="btn-outline-success"
|
||||||
icon="fa-file-excel-o"/>
|
icon="fa-file-excel-o"/>
|
||||||
<!-- <button name="action_assign_to_me"
|
|
||||||
type="object"
|
|
||||||
string="Tugaskan ke Saya"
|
|
||||||
class="btn-outline-primary"
|
|
||||||
icon="fa-user-check"/>
|
|
||||||
<button name="action_toggle_mock_mode"
|
|
||||||
type="object"
|
|
||||||
string="Ganti Mode (Mock / Live)"
|
|
||||||
class="btn-secondary"
|
|
||||||
icon="fa-exchange"/> -->
|
|
||||||
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
|
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<field name="is_detail_synced" invisible="1"/>
|
<field name="is_detail_synced" invisible="1"/>
|
||||||
<widget name="web_ribbon" title="SIMULASI MOCK DATA" bg_color="text-bg-info" invisible="not is_mock_data"/>
|
|
||||||
|
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<label for="name" string="Judul / Masalah Tiket"/>
|
<label for="name" string="Ticket Subject / Issue"/>
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -43,47 +33,53 @@
|
|||||||
<field name="current_responsibility" widget="badge"
|
<field name="current_responsibility" widget="badge"
|
||||||
decoration-danger="current_responsibility == 'internal'"
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
decoration-warning="current_responsibility == 'customer'"
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
|
decoration-info="current_responsibility == 'scheduled_meeting'"
|
||||||
|
decoration-muted="current_responsibility == 'third_party'"
|
||||||
decoration-success="current_responsibility == 'closed'"/>
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI METRIC CARDS SECTION -->
|
<!-- KPI METRIC CARDS SECTION -->
|
||||||
<div class="row mt-4 mb-4">
|
<div class="row mt-4 mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-primary">
|
<div class="card text-center p-3 shadow-sm border-primary">
|
||||||
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
||||||
<h4 class="mb-0 text-primary mt-1">
|
<h4 class="mb-0 text-primary mt-1"><field name="first_response_time_display" readonly="1"/></h4>
|
||||||
<field name="first_response_time_display" readonly="1"/>
|
<small class="text-secondary mt-1">Oleh: <field name="first_responder_name" readonly="1"/></small>
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">
|
|
||||||
Oleh: <field name="first_responder_name" readonly="1"/>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-danger">
|
<div class="card text-center p-3 shadow-sm border-danger">
|
||||||
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
||||||
<h4 class="mb-0 text-danger mt-1">
|
<h4 class="mb-0 text-danger mt-1"><field name="total_agent_time_display" readonly="1"/></h4>
|
||||||
<field name="total_agent_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Waiting on Us</small>
|
<small class="text-secondary mt-1">Waiting on Us</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-warning">
|
<div class="card text-center p-3 shadow-sm border-warning">
|
||||||
<small class="text-muted fw-bold text-uppercase">User</small>
|
<small class="text-muted fw-bold text-uppercase">User</small>
|
||||||
<h4 class="mb-0 text-warning mt-1">
|
<h4 class="mb-0 text-warning mt-1"><field name="total_customer_time_display" readonly="1"/></h4>
|
||||||
<field name="total_customer_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Waiting on Customer</small>
|
<small class="text-secondary mt-1">Waiting on Customer</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-info">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">Scheduled Meeting</small>
|
||||||
|
<h4 class="mb-0 text-info mt-1"><field name="shared_wait_time_display" readonly="1"/></h4>
|
||||||
|
<small class="text-secondary mt-1">Waiting Together</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card text-center p-3 shadow-sm border-secondary">
|
||||||
|
<small class="text-muted fw-bold text-uppercase">Third Party</small>
|
||||||
|
<h4 class="mb-0 text-secondary mt-1"><field name="third_party_time_display" readonly="1"/></h4>
|
||||||
|
<small class="text-secondary mt-1">Waiting on Vendor</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-success">
|
<div class="card text-center p-3 shadow-sm border-success">
|
||||||
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
||||||
<h4 class="mb-0 text-success mt-1">
|
<h4 class="mb-0 text-success mt-1"><field name="resolution_time_display" readonly="1"/></h4>
|
||||||
<field name="resolution_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Total Resolution</small>
|
<small class="text-secondary mt-1">Total Resolution</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,9 +111,10 @@
|
|||||||
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||||
<field name="stage_name" widget="badge"/>
|
<field name="stage_name" widget="badge"/>
|
||||||
<field name="responsibility" widget="badge"
|
<field name="responsibility" widget="badge"
|
||||||
decoration-danger="responsibility == 'internal'"
|
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
|
||||||
decoration-warning="responsibility == 'customer'"
|
decoration-warning="responsibility == 'customer'"
|
||||||
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/>
|
decoration-info="responsibility == 'scheduled_meeting'"
|
||||||
|
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
|
||||||
<field name="actor_name"/>
|
<field name="actor_name"/>
|
||||||
<field name="actor_type"/>
|
<field name="actor_type"/>
|
||||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
@@ -159,7 +156,11 @@
|
|||||||
<field name="first_response_time_days" readonly="1"/>
|
<field name="first_response_time_days" readonly="1"/>
|
||||||
<field name="total_agent_time_days" readonly="1"/>
|
<field name="total_agent_time_days" readonly="1"/>
|
||||||
<field name="total_customer_time_days" readonly="1"/>
|
<field name="total_customer_time_days" readonly="1"/>
|
||||||
|
<field name="shared_wait_time_days" readonly="1"/>
|
||||||
|
<field name="third_party_time_days" readonly="1"/>
|
||||||
<field name="resolution_time_days" readonly="1"/>
|
<field name="resolution_time_days" readonly="1"/>
|
||||||
|
<field name="shared_wait_time_hours" invisible="1"/>
|
||||||
|
<field name="third_party_time_hours" invisible="1"/>
|
||||||
<!-- Field jam disembunyikan (hide) -->
|
<!-- Field jam disembunyikan (hide) -->
|
||||||
<field name="first_response_time_hours" invisible="1"/>
|
<field name="first_response_time_hours" invisible="1"/>
|
||||||
<field name="total_agent_time_hours" invisible="1"/>
|
<field name="total_agent_time_hours" invisible="1"/>
|
||||||
@@ -180,7 +181,7 @@
|
|||||||
<field name="name">freshservice.ticket.tree</field>
|
<field name="name">freshservice.ticket.tree</field>
|
||||||
<field name="model">freshservice.ticket</field>
|
<field name="model">freshservice.ticket</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Daftar Tiket Freshservice"
|
<list string="Freshservice Tickets"
|
||||||
decoration-danger="current_responsibility == 'internal'"
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
decoration-warning="current_responsibility == 'customer'"
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
decoration-muted="status in ('resolved', 'closed')">
|
decoration-muted="status in ('resolved', 'closed')">
|
||||||
@@ -204,6 +205,8 @@
|
|||||||
decoration-success="current_responsibility == 'closed'"/>
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
<field name="total_agent_time_display"/>
|
<field name="total_agent_time_display"/>
|
||||||
<field name="total_customer_time_display"/>
|
<field name="total_customer_time_display"/>
|
||||||
|
<field name="shared_wait_time_display" optional="show"/>
|
||||||
|
<field name="third_party_time_display" optional="show"/>
|
||||||
<field name="first_response_time_display"/>
|
<field name="first_response_time_display"/>
|
||||||
<field name="responder_name"/>
|
<field name="responder_name"/>
|
||||||
<field name="requester_name"/>
|
<field name="requester_name"/>
|
||||||
@@ -297,17 +300,17 @@
|
|||||||
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
|
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
|
||||||
<filter name="filter_waiting_user" string="Waiting on Customer (User)" domain="[('current_responsibility', '=', 'customer')]"/>
|
<filter name="filter_waiting_user" string="Waiting on Requester (User)" domain="[('current_responsibility', '=', 'customer')]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="filter_open_progress" string="Tiket Aktif (Belum Selesai)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
<filter name="filter_open_progress" string="Active Tickets (Not Completed)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
||||||
<filter name="filter_resolved" string="Selesai (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
<filter name="filter_resolved" string="Completed (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
|
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
|
||||||
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
|
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
||||||
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/>
|
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/>
|
||||||
<filter name="group_by_responder" string="Agen (IT)" context="{'group_by': 'responder_name'}"/>
|
<filter name="group_by_responder" string="Agent (IT)" context="{'group_by': 'responder_name'}"/>
|
||||||
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
|
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form" position="inside">
|
<xpath expr="//form" position="inside">
|
||||||
<app data-string="Freshservice Audit" string="Freshservice Audit" name="freshservice_ticket_audit">
|
<app data-string="Freshservice Audit" string="Freshservice Audit" name="gmn_freshservice">
|
||||||
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
|
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
|
||||||
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
||||||
<field name="freshservice_url"/>
|
<field name="freshservice_url"/>
|
||||||
@@ -21,12 +21,11 @@
|
|||||||
<field name="freshservice_sync_limit"/>
|
<field name="freshservice_sync_limit"/>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
<setting id="freshservice_mock_setting" help="Gunakan mock data sesuai format resmi Freshservice API untuk testing tanpa API Key asli">
|
<setting id="freshservice_connection_setting" help="Uji koneksi ke Live Freshservice API">
|
||||||
<field name="freshservice_use_mock"/>
|
|
||||||
<div class="mt8">
|
<div class="mt8">
|
||||||
<button name="action_test_freshservice_connection"
|
<button name="action_test_freshservice_connection"
|
||||||
type="object"
|
type="object"
|
||||||
string="Tes Koneksi / Cek Status"
|
string="Tes Koneksi Live API"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-plug"/>
|
icon="fa-plug"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +41,6 @@
|
|||||||
<field name="res_model">res.config.settings</field>
|
<field name="res_model">res.config.settings</field>
|
||||||
<field name="view_mode">form</field>
|
<field name="view_mode">form</field>
|
||||||
<field name="target">main</field>
|
<field name="target">main</field>
|
||||||
<field name="context">{'module': 'freshservice_ticket_audit'}</field>
|
<field name="context">{'module': 'gmn_freshservice'}</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)'
|
||||||
|
|
||||||
@@ -43,6 +53,12 @@ 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(
|
||||||
|
string='Batas Waktu Cut-Off (As-Of)',
|
||||||
|
help='Batas waktu simulasi retrospektif (opsional). '
|
||||||
|
'Jika diisi, perhitungan durasi seluruh stage log dipotong sampai timestamp ini. '
|
||||||
|
'Kosongkan untuk perhitungan real-time hingga saat ini.',
|
||||||
|
)
|
||||||
|
|
||||||
status_filter = fields.Selection([
|
status_filter = fields.Selection([
|
||||||
('all', 'Semua Status'),
|
('all', 'Semua Status'),
|
||||||
@@ -67,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
|
||||||
|
|
||||||
@@ -80,6 +110,28 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
clean = re.sub(r'\s+', ' ', clean).strip()
|
clean = re.sub(r'\s+', ' ', clean).strip()
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
|
def _effective_responsibility(self, log):
|
||||||
|
"""Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review,
|
||||||
|
kalau belum pakai hasil deteksi otomatis.
|
||||||
|
"""
|
||||||
|
return log.override_responsibility or log.responsibility
|
||||||
|
|
||||||
|
def _clipped_stage_interval(self, log, report_as_of):
|
||||||
|
"""Potong interval stage log sesuai cut-off laporan.
|
||||||
|
Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu.
|
||||||
|
"""
|
||||||
|
start = log.start_date
|
||||||
|
if not start:
|
||||||
|
return False, False
|
||||||
|
if report_as_of and start >= report_as_of:
|
||||||
|
return False, False
|
||||||
|
end = log.end_date or report_as_of
|
||||||
|
if report_as_of and (not end or end > report_as_of):
|
||||||
|
end = report_as_of
|
||||||
|
if not end or end <= start:
|
||||||
|
return False, False
|
||||||
|
return start, end
|
||||||
|
|
||||||
def _ensure_ticket_details(self, ticket):
|
def _ensure_ticket_details(self, ticket):
|
||||||
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
|
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
|
||||||
if self.auto_sync_detail:
|
if self.auto_sync_detail:
|
||||||
@@ -109,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)."))
|
||||||
@@ -207,6 +264,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
|
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
|
||||||
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
|
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
|
||||||
})
|
})
|
||||||
|
scheduled_meeting_fmt = workbook.add_format({
|
||||||
|
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
|
||||||
|
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1,
|
||||||
|
'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
kpi_box_label = workbook.add_format({
|
kpi_box_label = workbook.add_format({
|
||||||
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
|
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
|
||||||
@@ -255,13 +317,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
summary_html = self._build_single_ticket_sheets(
|
summary_html = self._build_single_ticket_sheets(
|
||||||
workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
||||||
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
||||||
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt
|
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
|
||||||
|
scheduled_meeting_fmt
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
summary_html = self._build_ranking_sheets(
|
summary_html = self._build_ranking_sheets(
|
||||||
workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
||||||
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
||||||
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt
|
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
|
||||||
|
scheduled_meeting_fmt
|
||||||
)
|
)
|
||||||
|
|
||||||
workbook.close()
|
workbook.close()
|
||||||
@@ -309,7 +373,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
def _build_single_ticket_sheets(
|
def _build_single_ticket_sheets(
|
||||||
self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
||||||
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
||||||
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None
|
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
|
||||||
|
scheduled_meeting_fmt
|
||||||
):
|
):
|
||||||
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
|
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
|
||||||
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
|
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
|
||||||
@@ -324,19 +389,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws1.set_column('I:I', 14)
|
ws1.set_column('I:I', 14)
|
||||||
|
|
||||||
# Header Title
|
# Header Title
|
||||||
ws1.merge_range('B2:H2', f'LAPORAN ANALISIS TIKET #{t.external_ticket_id}', title_fmt)
|
ws1.merge_range('B2:H2', f'TICKET ANALYSIS REPORT #{t.external_ticket_id}', title_fmt)
|
||||||
|
cutoff_info = ""
|
||||||
|
if self.as_of:
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
as_of_wib = (self.as_of + _td(hours=7)).strftime('%d-%m-%Y %H:%M')
|
||||||
|
cutoff_info = f" | Cut-off As-Of: {as_of_wib} WIB"
|
||||||
ws1.write(
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
# Info Header Tiket
|
# Info Header Tiket
|
||||||
ws1.merge_range('B5:H5', 'Informasi Tiket', section_fmt)
|
ws1.merge_range('B5:H5', 'Ticket Information', section_fmt)
|
||||||
|
|
||||||
ws1.write('B6', 'ID Freshservice', td_fmt)
|
ws1.write('B6', 'ID Freshservice', td_fmt)
|
||||||
ws1.write('C6', f'#{t.external_ticket_id}', td_fmt)
|
ws1.write('C6', f'#{t.external_ticket_id}', td_fmt)
|
||||||
ws1.write('D6', 'Status Saat Ini', td_fmt)
|
ws1.write('D6', 'Current Status', td_fmt)
|
||||||
st_fmt = status_formats.get(t.status, td_center_fmt)
|
st_fmt = status_formats.get(t.status, td_center_fmt)
|
||||||
ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt)
|
ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt)
|
||||||
|
|
||||||
@@ -348,14 +418,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws1.write('D8', 'Departemen', td_fmt)
|
ws1.write('D8', 'Departemen', td_fmt)
|
||||||
ws1.write('E8', t.department_name or '-', td_fmt)
|
ws1.write('E8', t.department_name or '-', td_fmt)
|
||||||
|
|
||||||
ws1.write('B9', 'Agen IT Penanggung Jawab', td_fmt)
|
ws1.write('B9', 'Agent IT Penanggung Jawab', td_fmt)
|
||||||
ws1.write('C9', t.responder_name or '-', td_fmt)
|
ws1.write('C9', t.responder_name or '-', td_fmt)
|
||||||
ws1.write('D9', 'Prioritas', td_fmt)
|
ws1.write('D9', 'Prioritas', td_fmt)
|
||||||
ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt)
|
ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt)
|
||||||
|
|
||||||
ws1.write('B10', 'Waktu Dibuat', td_fmt)
|
ws1.write('B10', 'Created At', td_fmt)
|
||||||
ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt)
|
ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt)
|
||||||
ws1.write('D10', 'Waktu Selesai', td_fmt)
|
ws1.write('D10', 'Resolved At', td_fmt)
|
||||||
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
|
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
|
||||||
|
|
||||||
# KPI BOXES
|
# KPI BOXES
|
||||||
@@ -404,292 +474,404 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
|
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
|
||||||
'align': 'center', 'valign': 'vcenter'
|
'align': 'center', 'valign': 'vcenter'
|
||||||
})
|
})
|
||||||
|
# Warna KPI mengikuti responsibility: internal merah, customer orange,
|
||||||
|
# scheduled meeting biru, dan third party abu-abu.
|
||||||
|
kpi_internal_label = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA'
|
||||||
|
})
|
||||||
|
kpi_customer_label = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F'
|
||||||
|
})
|
||||||
|
kpi_meeting_label = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9'
|
||||||
|
})
|
||||||
|
kpi_third_party_label = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7'
|
||||||
|
})
|
||||||
|
kpi_internal_val = workbook.add_format({
|
||||||
|
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA',
|
||||||
|
'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
kpi_customer_val = workbook.add_format({
|
||||||
|
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
|
||||||
|
'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
kpi_meeting_val = workbook.add_format({
|
||||||
|
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9',
|
||||||
|
'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
kpi_third_party_val = workbook.add_format({
|
||||||
|
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
|
||||||
|
'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7',
|
||||||
|
'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
ws1.write('B13', 'First Response', kpi_wh_label)
|
ws1.write('B13', 'First Response', kpi_wh_label)
|
||||||
ws1.write('C13', 'Total Waktu', kpi_wh_label)
|
ws1.write('C13', 'Total Waktu', kpi_wh_label)
|
||||||
ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_wh_label)
|
ws1.write('D13', 'Agent Responsibility', kpi_internal_label)
|
||||||
ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label)
|
ws1.write('E13', 'Requester Responsibility', kpi_customer_label)
|
||||||
ws1.write('F13', 'Total SLA Paused', kpi_wh_label)
|
ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label)
|
||||||
ws1.write('G13', 'Resolved / Idle', kpi_wh_label)
|
ws1.write('G13', 'Third Party Responsibility', kpi_third_party_label)
|
||||||
ws1.write('H13', 'Catatan Rumus', kpi_wh_label)
|
ws1.write('H13', 'Catatan Rumus', kpi_wh_label)
|
||||||
|
|
||||||
# Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat)
|
# Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat)
|
||||||
wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0
|
wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0
|
||||||
wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0
|
wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0
|
||||||
|
|
||||||
report_as_of = fields.Datetime.now()
|
report_as_of = self.as_of or fields.Datetime.now()
|
||||||
wh_it_direct = 0.0
|
wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0
|
||||||
wh_internal_hold = 0.0
|
wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 0.0
|
||||||
wh_cust = 0.0
|
|
||||||
wh_meeting = 0.0
|
|
||||||
wh_vendor = 0.0
|
|
||||||
wh_other_paused = 0.0
|
|
||||||
wh_resolved_idle = 0.0
|
|
||||||
stage_wh_map = {}
|
stage_wh_map = {}
|
||||||
|
stage_resp_map = {}
|
||||||
for s in t.stage_log_ids:
|
for s in t.stage_log_ids:
|
||||||
# Segmen aktif dihitung hingga satu timestamp cetak yang konsisten.
|
start, end = self._clipped_stage_interval(s, report_as_of)
|
||||||
s_wh = compute_working_hours(self.env, s.start_date, s.end_date or report_as_of)
|
if not start:
|
||||||
|
stage_wh_map[s.id] = 0.0
|
||||||
|
continue
|
||||||
|
s_wh = compute_working_hours(self.env, start, end)
|
||||||
|
resp = self._effective_responsibility(s)
|
||||||
stage_wh_map[s.id] = s_wh
|
stage_wh_map[s.id] = s_wh
|
||||||
if s.responsibility == 'internal':
|
stage_resp_map[s.id] = resp
|
||||||
|
if resp == 'internal':
|
||||||
wh_it_direct += s_wh
|
wh_it_direct += s_wh
|
||||||
elif s.responsibility in ('on_hold', 'internal_discussion'):
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
# Sesuai Aturan 9: On Hold & Internal Discussion murni internal adalah Beban IT
|
wh_it_hold += s_wh
|
||||||
wh_internal_hold += s_wh
|
elif resp == 'customer':
|
||||||
elif s.responsibility == 'customer':
|
|
||||||
wh_cust += s_wh
|
wh_cust += s_wh
|
||||||
elif s.responsibility == 'scheduled_meeting':
|
elif resp == 'user_after_auto_resolve':
|
||||||
|
wh_user_auto += s_wh
|
||||||
|
elif resp == 'needs_verification':
|
||||||
|
wh_needs_verif += s_wh
|
||||||
|
elif resp == 'scheduled_meeting':
|
||||||
wh_meeting += s_wh
|
wh_meeting += s_wh
|
||||||
elif s.responsibility == 'third_party':
|
elif resp in ('third_party', 'external_activity'):
|
||||||
wh_vendor += s_wh
|
wh_vendor += s_wh
|
||||||
elif s.responsibility == 'external_activity':
|
elif resp == 'resolved_idle':
|
||||||
wh_other_paused += s_wh
|
|
||||||
elif s.responsibility == 'resolved_idle':
|
|
||||||
wh_resolved_idle += s_wh
|
wh_resolved_idle += s_wh
|
||||||
|
|
||||||
wh_agent = wh_it_direct + wh_internal_hold
|
wh_agent = wh_it_direct + wh_it_hold
|
||||||
wh_paused = wh_meeting + wh_vendor + wh_other_paused
|
wh_requester = wh_cust + wh_user_auto + wh_needs_verif
|
||||||
wh_total = sum(stage_wh_map.values())
|
wh_total = sum(stage_wh_map.values())
|
||||||
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
|
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
|
||||||
ws1.write('C14', format_working_duration(wh_total), kpi_box_val)
|
ws1.write('C14', format_working_duration(wh_total), kpi_box_val)
|
||||||
ws1.write('D14', format_working_duration(wh_agent), kpi_box_val)
|
ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val)
|
||||||
ws1.write('E14', format_working_duration(wh_cust), kpi_box_val)
|
ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val)
|
||||||
ws1.write('F14', format_working_duration(wh_paused), kpi_box_val)
|
ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val)
|
||||||
ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_val)
|
ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val)
|
||||||
ws1.write('H14', 'Senin-Jumat, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 Jam/Hari)\nDB UTC → +7 WIB', kpi_box_val)
|
ws1.write('H14', 'Monday-Friday, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 hours/day)\nDB UTC → +7 WIB', kpi_box_val)
|
||||||
|
|
||||||
# REKAPITULASI BEBAN TANGGUNG JAWAB — harus identik dengan total Sheet 2.
|
# RESPONSIBILITY RECAP — must match the Sheet 2 total.
|
||||||
ws1.merge_range('B16:I16', 'REKAPITULASI BEBAN TANGGUNG JAWAB & MATRIKS BOTTLENECK', section_fmt)
|
ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt)
|
||||||
ws1.write('B17', 'Kategori Beban', th_fmt)
|
ws1.write('B17', 'Responsibility Bucket', th_fmt)
|
||||||
ws1.merge_range('C17:F17', 'Keterangan Operasional', th_fmt)
|
ws1.merge_range('C17:F17', 'Operational Description', th_fmt)
|
||||||
ws1.write('G17', 'Jam Kerja', th_fmt)
|
ws1.write('G17', 'Working Hours', th_fmt)
|
||||||
ws1.write('H17', 'Hari Kerja Efektif', th_fmt)
|
ws1.write('H17', 'Effective Workdays', th_fmt)
|
||||||
ws1.write('I17', '% Beban', th_fmt)
|
ws1.write('I17', '% of Load', th_fmt)
|
||||||
recap_rows = [
|
recap_rows = [
|
||||||
('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / In Progress).', wh_it_direct),
|
('Agent Active Work', 'Direct Agent handling while the ticket is Open or In Progress.', wh_it_direct),
|
||||||
('Waiting on Us (IT - Internal Hold & Discussion)', 'Tiket ditahan/dianalisa internal IT (Aturan 9: default Beban IT).', wh_internal_hold),
|
('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold),
|
||||||
('Waiting on User (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust),
|
('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust),
|
||||||
('Tunggu Bersama (Scheduled Meeting)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting),
|
('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto),
|
||||||
('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor),
|
('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif),
|
||||||
('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused),
|
('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting),
|
||||||
('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle),
|
('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor),
|
||||||
('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total),
|
('Resolved / Idle Time', 'Resolved or closed time without a further attributable action.', wh_resolved_idle),
|
||||||
]
|
]
|
||||||
for recap_row, (label, description, hours) in enumerate(recap_rows, start=18):
|
# xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18.
|
||||||
label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (section_fmt if label == 'Grand Total Umur Tiket' else (sla_paused_fmt or td_center_fmt)))
|
# Ini menghindari satu baris kosong sebelum rekap pertama.
|
||||||
|
customer_recap_fmt = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
|
||||||
|
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
|
||||||
|
'align': 'left', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
customer_recap_number_fmt = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
|
||||||
|
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
|
||||||
|
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00'
|
||||||
|
})
|
||||||
|
customer_recap_pct_fmt = workbook.add_format({
|
||||||
|
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
|
||||||
|
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
|
||||||
|
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00%'
|
||||||
|
})
|
||||||
|
for recap_row, (label, description, hours) in enumerate(recap_rows, start=17):
|
||||||
|
is_customer = label.startswith('Waiting on Requester') or label.startswith('Waiting on User')
|
||||||
|
label_fmt = (
|
||||||
|
bn_badge_fmt if label.startswith('Agent ') else
|
||||||
|
customer_recap_number_fmt if is_customer else
|
||||||
|
scheduled_meeting_fmt if label.startswith('Scheduled') else
|
||||||
|
sla_paused_fmt or td_center_fmt
|
||||||
|
)
|
||||||
ws1.write(recap_row, 1, label, label_fmt)
|
ws1.write(recap_row, 1, label, label_fmt)
|
||||||
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
|
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
|
||||||
ws1.write(recap_row, 6, hours, td_dec_fmt)
|
excel_row = recap_row + 1
|
||||||
ws1.write(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
|
ws1.write_number(recap_row, 6, round(hours, 2), td_dec_fmt)
|
||||||
ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_fmt)
|
ws1.write_number(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
|
||||||
|
ws1.write_formula(recap_row, 8, f'=IF($G$26=0,0,G{excel_row}/$G$26)', td_pct_fmt)
|
||||||
|
|
||||||
|
# Grand total: Excel formula rather than a static number.
|
||||||
|
ws1.write(25, 1, 'Grand Total Ticket Lifetime', section_fmt)
|
||||||
|
ws1.merge_range(25, 2, 25, 5, 'Total across all eight responsibility buckets.', section_fmt)
|
||||||
|
ws1.write_formula(25, 6, '=SUM(G18:G25)', td_dec_fmt)
|
||||||
|
ws1.write_formula(25, 7, '=SUM(H18:H25)', td_dec_fmt)
|
||||||
|
ws1.write_formula(25, 8, '=SUM(I18:I25)', td_pct_fmt)
|
||||||
|
|
||||||
|
# Conservative IT workload range: the maximum includes pending user verification.
|
||||||
|
ws1.write('B27', 'Estimated Agent Workload Range', section_fmt)
|
||||||
|
ws1.write('C27', 'Minimum (Agent Active Work + Agent Hold)', td_fmt)
|
||||||
|
ws1.write_formula('D27', '=G18+G19', td_dec_fmt)
|
||||||
|
ws1.write('E27', 'Maximum (+ User Verification)', td_fmt)
|
||||||
|
ws1.write_formula('F27', '=D27+G22', td_dec_fmt)
|
||||||
|
|
||||||
|
# Aggregate ranking by the party responsible for the next action.
|
||||||
|
ws1.merge_range('B29:E29', 'AGGREGATE BOTTLENECK RANKING BY PARTY', section_fmt)
|
||||||
|
aggregate_rows = [
|
||||||
|
('Agent', wh_it_direct + wh_it_hold, 'Active work and internal hold.'),
|
||||||
|
('Requester', wh_cust + wh_user_auto + wh_needs_verif, 'Waiting on requester, auto-resolve & user verification.'),
|
||||||
|
('Shared', wh_meeting, 'Waiting for a scheduled meeting.'),
|
||||||
|
('Third Party', wh_vendor, 'Vendor or external party.')
|
||||||
|
]
|
||||||
|
if wh_resolved_idle > 0:
|
||||||
|
aggregate_rows.append(('Resolved / Idle', wh_resolved_idle, 'Resolved or closed idle time.'))
|
||||||
|
for col_idx, header in enumerate(['Party', 'Working Hours', 'Workdays', '% of Load'], start=1):
|
||||||
|
ws1.write(29, col_idx, header, th_fmt)
|
||||||
|
for aggregate_row, (party, hours, note) in enumerate(aggregate_rows, start=30):
|
||||||
|
ws1.write(aggregate_row, 1, party, td_fmt)
|
||||||
|
ws1.write_number(aggregate_row, 2, round(hours, 2), td_dec_fmt)
|
||||||
|
ws1.write_number(aggregate_row, 3, round(hours / 8.0, 2), td_dec_fmt)
|
||||||
|
ws1.write_formula(aggregate_row, 4, f'=IF($G$26=0,0,C{aggregate_row + 1}/$G$26)', td_pct_fmt)
|
||||||
|
|
||||||
# TABEL RANKING BOTTLENECK PER STAGE
|
# TABEL RANKING BOTTLENECK PER STAGE
|
||||||
# Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga
|
ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt)
|
||||||
# ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25.
|
headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status']
|
||||||
ws1.merge_range('B28:I28', 'RANKING BOTTLENECK PER STAGE (Diurutkan dari Tahapan Paling Lama)', section_fmt)
|
|
||||||
|
|
||||||
headers_bn = ['Rank', 'Tahapan / Status', 'Tanggung Jawab', 'Pengguna', 'Hari Kerja Efektif', 'Jam Kerja', '% Kontribusi', 'Status']
|
|
||||||
for col_idx, h in enumerate(headers_bn, start=1):
|
for col_idx, h in enumerate(headers_bn, start=1):
|
||||||
ws1.write(29, col_idx, h, th_fmt)
|
ws1.write(36, col_idx, h, th_fmt)
|
||||||
|
|
||||||
sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True)
|
sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True)
|
||||||
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
|
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
|
||||||
|
|
||||||
row = 30
|
row = 37
|
||||||
primary_bn_text = "Tidak terdeteksi delay signifikan."
|
|
||||||
primary_bn_actor = "-"
|
|
||||||
primary_bn_stage = "-"
|
|
||||||
primary_bn_days = 0
|
|
||||||
|
|
||||||
for rank_idx, stage in enumerate(sorted_stages, start=1):
|
for rank_idx, stage in enumerate(sorted_stages, start=1):
|
||||||
stage_wh = stage_wh_map.get(stage.id, 0.0)
|
stage_wh = stage_wh_map.get(stage.id, 0.0)
|
||||||
stage_wd_eff = round(stage_wh / 8.0, 2)
|
stage_wd_eff = round(stage_wh / 8.0, 2)
|
||||||
pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0
|
resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage))
|
||||||
if stage.responsibility == 'internal':
|
if resp == 'internal':
|
||||||
resp_label = 'Waiting on Us (Internal)'
|
resp_label = 'Agent Active Work'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = bn_badge_fmt
|
||||||
elif stage.responsibility == 'customer':
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
resp_label = 'Waiting on User (Requester)'
|
resp_label = 'Agent Hold & Discussion'
|
||||||
|
resp_fmt = bn_badge_fmt
|
||||||
|
elif resp == 'customer':
|
||||||
|
resp_label = 'Waiting on Requester'
|
||||||
resp_fmt = normal_badge_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'on_hold':
|
elif resp == 'user_after_auto_resolve':
|
||||||
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
|
resp_label = 'Waiting on User After Auto-Resolve'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'third_party':
|
elif resp == 'needs_verification':
|
||||||
resp_label = 'Waiting on Third Party (SLA is Paused)'
|
resp_label = 'Waiting on User Verification'
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'internal_discussion':
|
elif resp == 'scheduled_meeting':
|
||||||
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
|
resp_label = 'Scheduled Meeting (Shared)'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = scheduled_meeting_fmt
|
||||||
elif stage.responsibility == 'scheduled_meeting':
|
elif resp in ('third_party', 'external_activity'):
|
||||||
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
|
resp_label = 'Waiting on Third Party'
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
|
||||||
elif stage.responsibility == 'external_activity':
|
|
||||||
resp_label = 'External Activity (SLA is Paused)'
|
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
resp_fmt = sla_paused_fmt or td_center_fmt
|
||||||
|
elif resp == 'resolved_idle':
|
||||||
|
resp_label = 'Resolved / Idle Time'
|
||||||
|
resp_fmt = td_center_fmt
|
||||||
else:
|
else:
|
||||||
resp_label = 'Resolved (Ticket Closed/Idle)'
|
resp_label = 'Waiting on Requester'
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
is_primary = (rank_idx == 1 and stage_wh > 0.0)
|
is_primary = (rank_idx == 1 and stage_wh > 0.0)
|
||||||
|
|
||||||
ws1.write(row, 1, f"#{rank_idx}", bn_badge_fmt if is_primary else td_center_fmt)
|
ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt)
|
||||||
ws1.write(row, 2, stage.stage_name or '-', td_fmt)
|
ws1.write(row, 2, stage.stage_name or '-', td_fmt)
|
||||||
ws1.write(row, 3, resp_label, resp_fmt)
|
ws1.write(row, 3, resp_label, resp_fmt)
|
||||||
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
|
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
|
||||||
ws1.write(row, 5, stage_wd_eff, td_dec_fmt)
|
ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt)
|
||||||
ws1.write(row, 6, stage_wh, td_dec_fmt)
|
ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt)
|
||||||
ws1.write(row, 7, pct, td_pct_fmt)
|
ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt)
|
||||||
ws1.write(row, 8, 'Sedang Berjalan' if stage.is_active else 'Selesai', td_center_fmt)
|
ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# KESIMPULAN HIGHLIGHT BOX
|
# SHEET 2: AUDIT TRAIL & CHRONOLOGY
|
||||||
row += 1
|
ws2 = workbook.add_worksheet('Audit Trail & Chronology')
|
||||||
# ws1.merge_range(row, 1, row, 8, 'KESIMPULAN BOTTLENECK UNTUK BAHAN MEETING', section_fmt)
|
ws2.set_column('A:A', 4)
|
||||||
row += 1
|
|
||||||
# highlight_fmt = workbook.add_format({
|
|
||||||
# 'bold': True, 'font_size': 10, 'font_name': 'Calibri',
|
|
||||||
# 'bg_color': '#FDEDEC', 'font_color': '#78281F', 'border': 1, 'border_color': '#E6B0AA'
|
|
||||||
# })
|
|
||||||
# ws1.merge_range(row, 1, row + 1, 8, primary_bn_text, highlight_fmt)
|
|
||||||
|
|
||||||
# SHEET 2: KRONOLOGI AUDIT TRAIL
|
|
||||||
ws2 = workbook.add_worksheet('Audit Trail & Kronologi')
|
|
||||||
ws2.set_column('A:A', 5)
|
|
||||||
ws2.set_column('B:B', 6)
|
ws2.set_column('B:B', 6)
|
||||||
ws2.set_column('C:C', 22)
|
ws2.set_column('C:D', 18)
|
||||||
ws2.set_column('D:D', 22)
|
ws2.set_column('E:F', 24)
|
||||||
ws2.set_column('E:E', 22)
|
ws2.set_column('G:G', 28)
|
||||||
ws2.set_column('F:F', 26)
|
ws2.set_column('H:H', 14)
|
||||||
ws2.set_column('G:G', 16)
|
ws2.set_column('I:I', 20)
|
||||||
ws2.set_column('H:H', 22)
|
ws2.set_column('J:K', 16)
|
||||||
ws2.set_column('I:I', 18)
|
ws2.set_column('L:L', 20)
|
||||||
ws2.set_column('J:J', 18)
|
ws2.set_column('M:M', 32)
|
||||||
ws2.set_column('K:K', 35)
|
ws2.set_column('N:N', 16)
|
||||||
ws2.set_column('L:L', 55)
|
ws2.set_column('O:O', 35)
|
||||||
|
ws2.set_column('P:P', 55)
|
||||||
|
|
||||||
ws2.merge_range('B2:L2', f'KRONOLOGI LENGKAP AUDIT TRAIL TIKET #{t.external_ticket_id}', title_fmt)
|
ws2.merge_range('B2:P2', f'FULL AUDIT TRAIL CHRONOLOGY — TICKET #{t.external_ticket_id}', title_fmt)
|
||||||
headers_ws2 = [
|
headers_ws2 = [
|
||||||
'No', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
|
'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
|
||||||
'Tanggung Jawab', 'Tipe Pengguna', 'Nama Pengguna',
|
'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name',
|
||||||
'Hari Kerja Efektif', 'Jam Kerja',
|
'Effective Workdays', 'Working Hours', 'Delay Cause',
|
||||||
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
|
'Classification Basis', 'User Verification Required',
|
||||||
|
'Activity Details', 'Reply / Message Content'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers_ws2, start=1):
|
for col_idx, h in enumerate(headers_ws2, start=1):
|
||||||
ws2.write(3, col_idx, h, th_fmt)
|
ws2.write(3, col_idx, h, th_fmt)
|
||||||
|
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
def _to_wib_str(dt):
|
||||||
|
if not dt:
|
||||||
|
return 'Active'
|
||||||
|
try:
|
||||||
|
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
|
||||||
|
except Exception:
|
||||||
|
return str(dt)
|
||||||
|
|
||||||
row2 = 4
|
row2 = 4
|
||||||
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1):
|
current_idx2 = 0
|
||||||
if log.responsibility == 'internal':
|
for log in t.stage_log_ids.sorted('start_date'):
|
||||||
resp_label = 'Waiting on Us (Internal)'
|
start, end = self._clipped_stage_interval(log, report_as_of)
|
||||||
elif log.responsibility == 'customer':
|
if not start:
|
||||||
resp_label = 'Waiting on User (Customer)'
|
continue
|
||||||
elif log.responsibility == 'on_hold':
|
current_idx2 += 1
|
||||||
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
|
resp = stage_resp_map.get(log.id, self._effective_responsibility(log))
|
||||||
elif log.responsibility == 'third_party':
|
if resp == 'internal':
|
||||||
resp_label = 'Waiting on Third Party (SLA is Paused)'
|
resp_label = 'Agent Active Work'
|
||||||
elif log.responsibility == 'internal_discussion':
|
resp_fmt = bn_badge_fmt
|
||||||
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
elif log.responsibility == 'scheduled_meeting':
|
resp_label = 'Agent Hold & Discussion'
|
||||||
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
|
resp_fmt = bn_badge_fmt
|
||||||
elif log.responsibility == 'external_activity':
|
elif resp == 'customer':
|
||||||
resp_label = 'External Activity (SLA is Paused)'
|
resp_label = 'Waiting on Requester'
|
||||||
|
resp_fmt = normal_badge_fmt
|
||||||
|
elif resp == 'user_after_auto_resolve':
|
||||||
|
resp_label = 'Waiting on User After Auto-Resolve'
|
||||||
|
resp_fmt = normal_badge_fmt
|
||||||
|
elif resp == 'needs_verification':
|
||||||
|
resp_label = 'Waiting on User Verification'
|
||||||
|
resp_fmt = normal_badge_fmt
|
||||||
|
elif resp == 'scheduled_meeting':
|
||||||
|
resp_label = 'Scheduled Meeting (Shared)'
|
||||||
|
resp_fmt = scheduled_meeting_fmt
|
||||||
|
elif resp in ('third_party', 'external_activity'):
|
||||||
|
resp_label = 'Waiting on Third Party'
|
||||||
|
resp_fmt = sla_paused_fmt or td_center_fmt
|
||||||
|
elif resp == 'resolved_idle':
|
||||||
|
resp_label = 'Resolved / Idle Time'
|
||||||
|
resp_fmt = td_center_fmt
|
||||||
else:
|
else:
|
||||||
resp_label = 'Resolved (Ticket Closed/Idle)'
|
resp_label = 'Waiting on Requester'
|
||||||
|
resp_fmt = normal_badge_fmt
|
||||||
|
|
||||||
msg_body = self._get_stage_conversation_body(log, t)
|
msg_body = self._get_stage_conversation_body(log, t)
|
||||||
raw_act_main = log.activity_content or ''
|
raw_act_main = log.activity_content or ''
|
||||||
if 'Isi Pesan:' in raw_act_main:
|
if 'Isi Pesan:' in raw_act_main:
|
||||||
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
|
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
|
||||||
|
|
||||||
calc_days = compute_calendar_days(self.env, log.start_date, log.end_date or report_as_of)
|
wh = stage_wh_map.get(log.id, 0.0)
|
||||||
wh = compute_working_hours(self.env, log.start_date, log.end_date or report_as_of)
|
|
||||||
wd_eff = round(wh / 8.0, 2)
|
wd_eff = round(wh / 8.0, 2)
|
||||||
|
cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None')
|
||||||
|
verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No'
|
||||||
|
|
||||||
# Tampilkan waktu dalam WIB (UTC+7) untuk kemudahan baca
|
effective_end = end if (self.as_of and end) else log.end_date
|
||||||
from datetime import timedelta as _td
|
ws2.write(row2, 1, current_idx2, td_center_fmt)
|
||||||
def _to_wib_str(dt):
|
ws2.write(row2, 2, _to_wib_str(start), td_center_fmt)
|
||||||
if not dt:
|
ws2.write(row2, 3, _to_wib_str(effective_end), td_center_fmt)
|
||||||
return 'Aktif'
|
|
||||||
try:
|
|
||||||
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
|
|
||||||
except Exception:
|
|
||||||
return str(dt)
|
|
||||||
|
|
||||||
ws2.write(row2, 1, idx2, td_center_fmt)
|
|
||||||
ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt)
|
|
||||||
ws2.write(row2, 3, _to_wib_str(log.end_date), td_center_fmt)
|
|
||||||
ws2.write(row2, 4, log.stage_name or '-', td_fmt)
|
ws2.write(row2, 4, log.stage_name or '-', td_fmt)
|
||||||
ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt)))
|
ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt)
|
||||||
ws2.write(row2, 6, log.actor_type or '-', td_center_fmt)
|
ws2.write(row2, 6, resp_label, resp_fmt)
|
||||||
ws2.write(row2, 7, log.actor_name or '-', td_fmt)
|
ws2.write(row2, 7, log.actor_type or '-', td_center_fmt)
|
||||||
ws2.write(row2, 8, wd_eff, td_dec_fmt)
|
ws2.write(row2, 8, log.actor_name or '-', td_fmt)
|
||||||
ws2.write(row2, 9, wh, td_dec_fmt)
|
ws2.write_number(row2, 9, wd_eff, td_dec_fmt)
|
||||||
ws2.write(row2, 10, self._strip_html(raw_act_main) or '-', td_fmt)
|
ws2.write_number(row2, 10, round(wh, 2), td_dec_fmt)
|
||||||
ws2.write(row2, 11, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
|
ws2.write(row2, 11, cause_label, td_center_fmt)
|
||||||
|
ws2.write(row2, 12, log.classification_basis or '-', td_fmt)
|
||||||
|
ws2.write(row2, 13, verif_str, td_center_fmt)
|
||||||
|
ws2.write(row2, 14, self._strip_html(raw_act_main) or '-', td_fmt)
|
||||||
|
ws2.write(row2, 15, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
|
||||||
row2 += 1
|
row2 += 1
|
||||||
|
|
||||||
# Grand total menggunakan sumber data yang sama dengan Sheet 1 (stage_wh_map).
|
# Grand total using Excel formulas
|
||||||
ws2.merge_range(row2, 1, row2, 7, 'GRAND TOTAL UMUR TIKET', section_fmt)
|
ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt)
|
||||||
ws2.write(row2, 8, round(wh_total / 8.0, 2), td_dec_fmt)
|
ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt)
|
||||||
ws2.write(row2, 9, wh_total, td_dec_fmt)
|
ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', td_dec_fmt)
|
||||||
ws2.merge_range(row2, 10, row2, 11, 'Sama dengan Sheet 1: seluruh stage log termasuk SLA Paused dan Resolved / Idle.', td_fmt)
|
ws2.merge_range(row2, 11, row2, 15, 'Matches Sheet 1: all stage logs calculated using effective business hours.', td_fmt)
|
||||||
|
|
||||||
# SHEET 3: RIWAYAT PERCAKAPAN
|
# SHEET 3: CONVERSATIONS & NOTES
|
||||||
ws3 = workbook.add_worksheet('Percakapan & Respon')
|
ws3 = workbook.add_worksheet('Conversations & Notes')
|
||||||
ws3.set_column('A:A', 5)
|
ws3.set_column('A:A', 5)
|
||||||
ws3.set_column('B:B', 8)
|
ws3.set_column('B:B', 8)
|
||||||
ws3.set_column('C:C', 18)
|
ws3.set_column('C:C', 20)
|
||||||
ws3.set_column('D:D', 22)
|
ws3.set_column('D:D', 22)
|
||||||
ws3.set_column('E:E', 24)
|
ws3.set_column('E:E', 24)
|
||||||
ws3.set_column('F:F', 55)
|
ws3.set_column('F:F', 55)
|
||||||
|
|
||||||
ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt)
|
ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt)
|
||||||
headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan']
|
headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content']
|
||||||
for col_idx, h in enumerate(headers_ws3, start=1):
|
for col_idx, h in enumerate(headers_ws3, start=1):
|
||||||
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
|
||||||
sender_type_label = 'User (Requester)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent'
|
for conv in t.conversation_ids.sorted('created_at'):
|
||||||
ws3.write(row3, 1, idx3, td_center_fmt)
|
if report_as_of and conv.created_at and conv.created_at > report_as_of:
|
||||||
ws3.write(row3, 2, str(conv.created_at or '-'), td_center_fmt)
|
continue
|
||||||
|
current_idx3 += 1
|
||||||
|
sender_type_label = 'Requester (User)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent (IT)'
|
||||||
|
conv_wib_str = _to_wib_str(conv.created_at)
|
||||||
|
ws3.write(row3, 1, current_idx3, td_center_fmt)
|
||||||
|
ws3.write(row3, 2, conv_wib_str, td_center_fmt)
|
||||||
ws3.write(row3, 3, sender_type_label, normal_badge_fmt if conv.incoming else bn_badge_fmt)
|
ws3.write(row3, 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)
|
||||||
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
|
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
|
||||||
row3 += 1
|
row3 += 1
|
||||||
|
|
||||||
# Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard
|
# HTML summary for wizard pop-up dialog
|
||||||
html_out = f"""
|
html_out = f"""
|
||||||
<div class="alert alert-info border shadow-sm">
|
<div class="alert alert-info border shadow-sm">
|
||||||
<h4 class="alert-heading fw-bold mb-2">📊 Hasil Analisis Bottleneck Tiket #{t.external_ticket_id}</h4>
|
<h4 class="alert-heading fw-bold mb-2">📊 Ticket Bottleneck Analysis #{t.external_ticket_id}</h4>
|
||||||
<p class="mb-1"><strong>Subjek:</strong> {t.name or '-'}</p>
|
<p class="mb-1"><strong>Subject:</strong> {t.name or '-'}</p>
|
||||||
<p class="mb-2"><strong>Pemohon:</strong> {t.requester_name or '-'} | <strong>Departemen:</strong> {t.department_name or '-'} | <strong>Agen IT:</strong> {t.responder_name or '-'}</p>
|
<p class="mb-2"><strong>Requester:</strong> {t.requester_name or '-'} | <strong>Department:</strong> {t.department_name or '-'} | <strong>Agent Agent:</strong> {t.responder_name or '-'}</p>
|
||||||
<hr class="my-2"/>
|
<hr class="my-2"/>
|
||||||
<div class="row text-center mb-3">
|
<div class="row text-center mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border">
|
<div class="bg-light p-2 rounded border">
|
||||||
<small class="text-muted d-block">TOTAL WAKTU</small>
|
<small class="text-muted d-block">TOTAL DURATION</small>
|
||||||
<strong class="text-dark fs-5">{t.resolution_time_days} Hari</strong>
|
<strong class="text-dark fs-5">{t.resolution_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-danger">
|
<div class="bg-light p-2 rounded border border-danger">
|
||||||
<small class="text-danger d-block fw-bold">WAITING ON US (IT)</small>
|
<small class="text-danger d-block fw-bold">AGENT RESPONSIBILITY</small>
|
||||||
<strong class="text-danger fs-5">{t.total_agent_time_days} Hari</strong>
|
<strong class="text-danger fs-5">{t.total_agent_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-warning">
|
<div class="bg-light p-2 rounded border border-warning">
|
||||||
<small class="text-warning d-block fw-bold">WAITING ON USER</small>
|
<small class="text-warning d-block fw-bold">REQUESTER RESPONSIBILITY</small>
|
||||||
<strong class="text-warning fs-5">{t.total_customer_time_days} Hari</strong>
|
<strong class="text-warning fs-5">{t.total_customer_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-primary">
|
<div class="bg-light p-2 rounded border border-primary">
|
||||||
<small class="text-primary d-block fw-bold">FIRST RESPONSE</small>
|
<small class="text-primary d-block fw-bold">FIRST RESPONSE</small>
|
||||||
<strong class="text-primary fs-5">{t.first_response_time_days} Hari</strong>
|
<strong class="text-primary fs-5">{t.first_response_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
return html_out
|
||||||
# <div class="alert alert-danger mb-0">
|
# <div class="alert alert-danger mb-0">
|
||||||
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
|
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
|
||||||
# {primary_bn_text}
|
# {primary_bn_text}
|
||||||
@@ -699,10 +881,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
def _build_ranking_sheets(
|
def _build_ranking_sheets(
|
||||||
self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
||||||
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
|
||||||
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None
|
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats,
|
||||||
|
sla_paused_fmt=None, scheduled_meeting_fmt=None
|
||||||
):
|
):
|
||||||
"""Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim)."""
|
"""Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim)."""
|
||||||
ws = workbook.add_worksheet('Ranking Bottleneck Tiket')
|
scheduled_meeting_fmt = scheduled_meeting_fmt or sla_paused_fmt or td_center_fmt
|
||||||
|
ws = workbook.add_worksheet('Ticket Bottleneck Ranking')
|
||||||
ws.set_column('A:A', 5)
|
ws.set_column('A:A', 5)
|
||||||
ws.set_column('B:B', 8)
|
ws.set_column('B:B', 8)
|
||||||
ws.set_column('C:C', 14)
|
ws.set_column('C:C', 14)
|
||||||
@@ -721,20 +905,20 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws.set_column('P:P', 16)
|
ws.set_column('P:P', 16)
|
||||||
ws.set_column('Q:Q', 16)
|
ws.set_column('Q:Q', 16)
|
||||||
|
|
||||||
ws.merge_range('B2:Q2', 'LAPORAN RANKING BOTTLENECK & EVALUASI SLA TIKET (BAHAN MEETING)', title_fmt)
|
ws.merge_range('B2:Q2', 'BOTTLENECK RANKING & SLA EVALUATION REPORT', title_fmt)
|
||||||
ws.write(
|
ws.write(
|
||||||
'B3',
|
'B3',
|
||||||
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
|
f'Printed: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Printed by: {self.env.user.name}',
|
||||||
subtitle_fmt
|
subtitle_fmt
|
||||||
)
|
)
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT',
|
'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent',
|
||||||
'Status',
|
'Status',
|
||||||
'Total Hari Kalender', 'Total Jam Kerja (08:30-17:30)', 'Total Hari Kerja Efektif',
|
'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays',
|
||||||
'Waktu IT (Hari Kalender)', 'Waktu IT (Jam Kerja)',
|
'Agent Time (Calendar Days)', 'Agent Time (Working Hours)',
|
||||||
'Waktu User (Hari Kalender)', 'Waktu User (Jam Kerja)',
|
'User Time (Calendar Days)', 'User Time (Working Hours)',
|
||||||
'Bottleneck Utama (Stage)', 'Durasi Bottleneck (Hari Kalender)', 'Durasi Bottleneck (Jam Kerja)'
|
'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers, start=1):
|
for col_idx, h in enumerate(headers, start=1):
|
||||||
ws.write(4, col_idx, h, th_fmt)
|
ws.write(4, col_idx, h, th_fmt)
|
||||||
@@ -753,7 +937,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
top_wh = 0.0
|
top_wh = 0.0
|
||||||
if top_stage:
|
if top_stage:
|
||||||
top_stage_name = top_stage[0].stage_name or '-'
|
top_stage_name = top_stage[0].stage_name or '-'
|
||||||
top_resp = 'Internal (IT)' if top_stage[0].responsibility == 'internal' else 'Customer (User)'
|
top_resp = 'Agent Active Work' if top_stage[0].responsibility == 'internal' else 'Waiting on Requester'
|
||||||
top_days = top_stage[0].duration_days
|
top_days = top_stage[0].duration_days
|
||||||
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
|
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
|
||||||
|
|
||||||
@@ -763,16 +947,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
t_wh_cust = 0.0
|
t_wh_cust = 0.0
|
||||||
for s in t.stage_log_ids:
|
for s in t.stage_log_ids:
|
||||||
s_wh = compute_working_hours(self.env, s.start_date, s.end_date)
|
s_wh = compute_working_hours(self.env, s.start_date, s.end_date)
|
||||||
if s.responsibility == 'internal':
|
resp = s.override_responsibility or s.responsibility
|
||||||
|
if resp in ('internal', 'it_hold', 'on_hold', 'internal_discussion'):
|
||||||
t_wh_agent += s_wh
|
t_wh_agent += s_wh
|
||||||
elif s.responsibility == 'customer':
|
elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'):
|
||||||
t_wh_cust += s_wh
|
t_wh_cust += s_wh
|
||||||
# on_hold: SLA paused, tidak dihitung di IT maupun User
|
|
||||||
|
|
||||||
t_wd_eff = round(t_wh_total / 8.0, 2)
|
t_wd_eff = round(t_wh_total / 8.0, 2)
|
||||||
|
|
||||||
if rank_idx <= 5:
|
if rank_idx <= 5:
|
||||||
slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Hari / {t_wh_total:.1f} Jam Kerja)")
|
slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Days / {t_wh_total:.1f} Working Hours)")
|
||||||
|
|
||||||
ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt)
|
ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt)
|
||||||
ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt)
|
ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt)
|
||||||
@@ -795,7 +979,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET
|
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET
|
||||||
ws2 = workbook.add_worksheet('Detail Stage Log Semua Tiket')
|
ws2 = workbook.add_worksheet('All Tickets Stage Logs')
|
||||||
ws2.set_column('A:A', 5)
|
ws2.set_column('A:A', 5)
|
||||||
ws2.set_column('B:B', 14)
|
ws2.set_column('B:B', 14)
|
||||||
ws2.set_column('C:C', 22)
|
ws2.set_column('C:C', 22)
|
||||||
@@ -810,13 +994,13 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws2.set_column('L:L', 32)
|
ws2.set_column('L:L', 32)
|
||||||
ws2.set_column('M:M', 50)
|
ws2.set_column('M:M', 50)
|
||||||
|
|
||||||
ws2.merge_range('B2:M2', 'RINCIAN SELURUH TRANSISI STAGE & DURASI TIKET', title_fmt)
|
ws2.merge_range('B2:M2', 'ALL TICKET STAGE TRANSITIONS & DURATIONS', title_fmt)
|
||||||
headers2 = [
|
headers2 = [
|
||||||
'ID Tiket', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
|
'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
|
||||||
'Tanggung Jawab', 'Pengguna',
|
'Responsibility', 'Actor',
|
||||||
'Hari Kalender', 'Jam Kalender',
|
'Calendar Days', 'Calendar Hours',
|
||||||
'Jam Kerja (08:30-17:30)', 'Hari Kerja Efektif',
|
'Working Hours (08:30-17:30)', 'Effective Workdays',
|
||||||
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
|
'Activity Details', 'Reply / Message Content'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers2, start=1):
|
for col_idx, h in enumerate(headers2, start=1):
|
||||||
ws2.write(3, col_idx, h, th_fmt)
|
ws2.write(3, col_idx, h, th_fmt)
|
||||||
@@ -825,15 +1009,23 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
for t in sorted_tickets:
|
for t in sorted_tickets:
|
||||||
for log in t.stage_log_ids.sorted('start_date'):
|
for log in t.stage_log_ids.sorted('start_date'):
|
||||||
if log.responsibility == 'internal':
|
if log.responsibility == 'internal':
|
||||||
resp_label = 'Internal (Waiting on Us)'
|
resp_label = 'Agent Active Work'
|
||||||
elif log.responsibility == 'customer':
|
elif log.responsibility == 'customer':
|
||||||
resp_label = 'Customer (Waiting on User)'
|
resp_label = 'Waiting on Requester'
|
||||||
elif log.responsibility == 'on_hold':
|
elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'):
|
||||||
resp_label = 'On Hold (SLA is Paused)'
|
resp_label = 'Agent Hold & Discussion'
|
||||||
elif log.responsibility == 'third_party':
|
elif log.responsibility == 'third_party':
|
||||||
resp_label = 'Waiting on Third Party (SLA is Paused)'
|
resp_label = 'Waiting on Third Party'
|
||||||
|
elif log.responsibility == 'scheduled_meeting':
|
||||||
|
resp_label = 'Scheduled Meeting (Shared)'
|
||||||
|
elif log.responsibility == 'user_after_auto_resolve':
|
||||||
|
resp_label = 'Waiting on User After Auto-Resolve'
|
||||||
|
elif log.responsibility == 'needs_verification':
|
||||||
|
resp_label = 'Waiting on User Verification'
|
||||||
|
elif log.responsibility == 'resolved_idle':
|
||||||
|
resp_label = 'Resolved / Idle Time'
|
||||||
else:
|
else:
|
||||||
resp_label = 'Waiting on Internal Discussion (SLA is Paused)'
|
resp_label = 'Waiting on Requester'
|
||||||
msg_body = self._get_stage_conversation_body(log, t)
|
msg_body = self._get_stage_conversation_body(log, t)
|
||||||
raw_act_main = log.activity_content or ''
|
raw_act_main = log.activity_content or ''
|
||||||
if 'Isi Pesan:' in raw_act_main:
|
if 'Isi Pesan:' in raw_act_main:
|
||||||
@@ -846,7 +1038,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
from datetime import timedelta as _td2
|
from datetime import timedelta as _td2
|
||||||
def _wib(dt):
|
def _wib(dt):
|
||||||
if not dt:
|
if not dt:
|
||||||
return 'Aktif'
|
return 'Active'
|
||||||
try:
|
try:
|
||||||
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
|
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -856,7 +1048,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws2.write(row2, 2, _wib(log.start_date), td_center_fmt)
|
ws2.write(row2, 2, _wib(log.start_date), td_center_fmt)
|
||||||
ws2.write(row2, 3, _wib(log.end_date), td_center_fmt)
|
ws2.write(row2, 3, _wib(log.end_date), 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, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt)))
|
resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'it_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility in ('customer', 'user_after_auto_resolve', 'needs_verification') else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt)))
|
||||||
|
ws2.write(row2, 5, resp_label, resp_fmt)
|
||||||
ws2.write(row2, 6, log.actor_name or '-', td_fmt)
|
ws2.write(row2, 6, log.actor_name or '-', td_fmt)
|
||||||
ws2.write(row2, 7, calc_log_days, td_num_fmt)
|
ws2.write(row2, 7, calc_log_days, td_num_fmt)
|
||||||
ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
|
ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
|
||||||
@@ -869,15 +1062,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
|
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
|
||||||
html_out = f"""
|
html_out = f"""
|
||||||
<div class="alert alert-info border shadow-sm">
|
<div class="alert alert-info border shadow-sm">
|
||||||
<h4 class="alert-heading fw-bold mb-2">📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat</h4>
|
<h4 class="alert-heading fw-bold mb-2">📋 Multi-Ticket Bottleneck Ranking Report Generated</h4>
|
||||||
<p class="mb-1">Total Tiket yang Dievaluasi: <strong>{len(sorted_tickets)} Tiket</strong></p>
|
<p class="mb-1">Total Evaluated Tickets: <strong>{len(sorted_tickets)} Tickets</strong></p>
|
||||||
<p class="mb-2">Rata-rata Waktu Penyelesaian: <strong>{avg_days:.1f} Hari Kalender</strong></p>
|
<p class="mb-2">Average Resolution Time: <strong>{avg_days:.1f} Calendar Days</strong></p>
|
||||||
<hr class="my-2"/>
|
<hr class="my-2"/>
|
||||||
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tiket dengan Durasi Terlama:</p>
|
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tickets with Longest Duration:</p>
|
||||||
<ul class="mb-2">
|
<ul class="mb-2">
|
||||||
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
|
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
|
||||||
</ul>
|
</ul>
|
||||||
<small class="text-muted">File Excel siap digunakan sebagai bahan evaluasi dan meeting manajemen.</small>
|
<small class="text-muted">Excel file is ready for team evaluation and management meetings.</small>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return html_out
|
return html_out
|
||||||
|
return html_out
|
||||||
|
|||||||
@@ -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 & 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user