[UPD] initial commit
This commit is contained in:
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.
|
||||
7
gmn_freshservice/__init__.py
Normal file
7
gmn_freshservice/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Modul utama GMN Freshservice.
|
||||
Import semua sub-package: models, services, dan wizard.
|
||||
"""
|
||||
from . import models
|
||||
from . import services
|
||||
from . import wizard
|
||||
38
gmn_freshservice/__manifest__.py
Normal file
38
gmn_freshservice/__manifest__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'GMN Freshservice Ticket Audit Trail & Time Metrics',
|
||||
# 'version': '18.0.1.0.0',
|
||||
'category': 'Services/Helpdesk',
|
||||
'summary': 'Pelacakan Durasi Tiket (Waiting on Us vs Waiting on User) & Audit Trail Terintegrasi Freshservice API',
|
||||
'description': """
|
||||
Freshservice Ticket Audit Trail & Time Tracking (Odoo 19)
|
||||
=========================================================
|
||||
Modul untuk melacak audit trail tiket dan memisahkan durasi penanganan:
|
||||
* First Response Time & Pelaku Responder Pertama
|
||||
* Durasi Penanganan di Sisi Internal (Waiting on Us / Agent Time)
|
||||
* Durasi Penanganan di Sisi User (Waiting on Customer / User Time)
|
||||
* Dilengkapi Mock Data Freshservice API v2 sesuai dokumentasi resmi
|
||||
* Siap switch ke Live Freshservice API kapan saja tanpa mengubah arsitektur data.
|
||||
""",
|
||||
'author': 'IT Development Team',
|
||||
'depends': [
|
||||
'base',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/gmn_freshservice_security_v01.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/gmn_demo_data_v01.xml',
|
||||
'views/gmn_res_config_settings_views_v01.xml',
|
||||
'views/gmn_freshservice_ticket_stage_log_views_v01.xml',
|
||||
'views/gmn_freshservice_holiday_views_v01.xml',
|
||||
'wizard/gmn_freshservice_holiday_import_wizard_views_v01.xml',
|
||||
'wizard/gmn_freshservice_ticket_bottleneck_wizard_views_v01.xml',
|
||||
'views/gmn_freshservice_ticket_views_v01.xml',
|
||||
'views/gmn_menu_views_v01.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
BIN
gmn_freshservice/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/__pycache__/__manifest__.cpython-314.pyc
Normal file
Binary file not shown.
123
gmn_freshservice/data/gmn_demo_data_v01.xml
Normal file
123
gmn_freshservice/data/gmn_demo_data_v01.xml
Normal file
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- TIKET CONTOH FRESHSERVICE #129 -->
|
||||
<record id="demo_freshservice_ticket_129" model="freshservice.ticket">
|
||||
<field name="name">Kendala Koneksi Jaringan & SAP Client - Dept Finance</field>
|
||||
<field name="external_ticket_id">129</field>
|
||||
<field name="description">Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.</field>
|
||||
<field name="status">resolved</field>
|
||||
<field name="priority">high</field>
|
||||
<field name="requester_name">Ahmad Dani (User)</field>
|
||||
<field name="requester_email">ahmad.dani@lyman.co.id</field>
|
||||
<field name="responder_name">Budi Santoso (IT Support)</field>
|
||||
<field name="department_name">Finance & Accounting</field>
|
||||
<field name="ticket_created_at">2026-10-10 08:30:00</field>
|
||||
<field name="due_by">2026-10-12 17:00:00</field>
|
||||
<field name="first_responded_at">2026-10-10 08:52:00</field>
|
||||
<field name="first_responder_name">Budi Santoso (IT Support)</field>
|
||||
<field name="resolved_at">2026-10-11 16:30:00</field>
|
||||
<field name="is_mock_data">1</field>
|
||||
</record>
|
||||
|
||||
<!-- STAGE LOG 1: OPEN (INTERNAL) -->
|
||||
<record id="demo_stage_log_1" model="freshservice.ticket.stage.log">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="stage_name">Open</field>
|
||||
<field name="responsibility">internal</field>
|
||||
<field name="actor_name">Ahmad Dani (User)</field>
|
||||
<field name="actor_type">Requester</field>
|
||||
<field name="activity_content">Tiket dibuat via Web Portal dengan status Open</field>
|
||||
<field name="start_date">2026-10-10 08:30:00</field>
|
||||
<field name="end_date">2026-10-10 08:52:00</field>
|
||||
</record>
|
||||
|
||||
<!-- STAGE LOG 2: FIRST RESPONSE / IN PROGRESS (INTERNAL) -->
|
||||
<record id="demo_stage_log_2" model="freshservice.ticket.stage.log">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="stage_name">In Progress</field>
|
||||
<field name="responsibility">internal</field>
|
||||
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||
<field name="actor_type">Agent</field>
|
||||
<field name="activity_content">First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.</field>
|
||||
<field name="start_date">2026-10-10 08:52:00</field>
|
||||
<field name="end_date">2026-10-10 09:30:00</field>
|
||||
</record>
|
||||
|
||||
<!-- STAGE LOG 3: PENDING USER (CUSTOMER) -->
|
||||
<record id="demo_stage_log_3" model="freshservice.ticket.stage.log">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="stage_name">Pending User</field>
|
||||
<field name="responsibility">customer</field>
|
||||
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||
<field name="actor_type">Agent</field>
|
||||
<field name="activity_content">Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.</field>
|
||||
<field name="start_date">2026-10-10 09:30:00</field>
|
||||
<field name="end_date">2026-10-11 14:00:00</field>
|
||||
</record>
|
||||
|
||||
<!-- STAGE LOG 4: IN PROGRESS KEMBALI SETELAH USER BALAS (INTERNAL) -->
|
||||
<record id="demo_stage_log_4" model="freshservice.ticket.stage.log">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="stage_name">In Progress</field>
|
||||
<field name="responsibility">internal</field>
|
||||
<field name="actor_name">Ahmad Dani (User)</field>
|
||||
<field name="actor_type">Requester</field>
|
||||
<field name="activity_content">User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.</field>
|
||||
<field name="start_date">2026-10-11 14:00:00</field>
|
||||
<field name="end_date">2026-10-11 16:30:00</field>
|
||||
</record>
|
||||
|
||||
<!-- STAGE LOG 5: RESOLVED (INTERNAL) -->
|
||||
<record id="demo_stage_log_5" model="freshservice.ticket.stage.log">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="stage_name">Resolved</field>
|
||||
<field name="responsibility">internal</field>
|
||||
<field name="actor_name">Budi Santoso (IT Support)</field>
|
||||
<field name="actor_type">Agent</field>
|
||||
<field name="activity_content">Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.</field>
|
||||
<field name="start_date">2026-10-11 16:30:00</field>
|
||||
<field name="end_date">2026-10-11 16:30:00</field>
|
||||
</record>
|
||||
|
||||
<!-- PERCAKAPAN 1 -->
|
||||
<record id="demo_conversation_1" model="freshservice.ticket.conversation">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="external_id">501</field>
|
||||
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||
<field name="incoming">0</field>
|
||||
<field name="body_text">Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.</field>
|
||||
<field name="created_at">2026-10-10 08:52:00</field>
|
||||
</record>
|
||||
|
||||
<!-- PERCAKAPAN 2 -->
|
||||
<record id="demo_conversation_2" model="freshservice.ticket.conversation">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="external_id">502</field>
|
||||
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||
<field name="incoming">0</field>
|
||||
<field name="body_text">Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?</field>
|
||||
<field name="created_at">2026-10-10 09:30:00</field>
|
||||
</record>
|
||||
|
||||
<!-- PERCAKAPAN 3 -->
|
||||
<record id="demo_conversation_3" model="freshservice.ticket.conversation">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="external_id">503</field>
|
||||
<field name="user_name">Ahmad Dani (User)</field>
|
||||
<field name="incoming">1</field>
|
||||
<field name="body_text">Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.</field>
|
||||
<field name="created_at">2026-10-11 14:00:00</field>
|
||||
</record>
|
||||
|
||||
<!-- PERCAKAPAN 4 -->
|
||||
<record id="demo_conversation_4" model="freshservice.ticket.conversation">
|
||||
<field name="ticket_id" ref="demo_freshservice_ticket_129"/>
|
||||
<field name="external_id">504</field>
|
||||
<field name="user_name">Budi Santoso (IT Support)</field>
|
||||
<field name="incoming">0</field>
|
||||
<field name="body_text">Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.</field>
|
||||
<field name="created_at">2026-10-11 16:30:00</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
9
gmn_freshservice/models/__init__.py
Normal file
9
gmn_freshservice/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package models: semua model Odoo untuk modul Freshservice.
|
||||
Urutan import penting karena ticket bergantung pada stage_log dan conversation.
|
||||
"""
|
||||
from . import gmn_res_config_settings_v01
|
||||
from . import gmn_freshservice_holiday_v01
|
||||
from . import gmn_freshservice_ticket_stage_log_v01
|
||||
from . import gmn_freshservice_ticket_conversation_v01
|
||||
from . import gmn_freshservice_ticket_v01
|
||||
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice.
|
||||
Setiap baris adalah satu pesan, baik dari user maupun dari agen IT.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FreshserviceTicketConversation(models.Model):
|
||||
"""Menyimpan pesan/balasan pada tiket.
|
||||
Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel.
|
||||
"""
|
||||
_name = 'freshservice.ticket.conversation'
|
||||
_description = 'Percakapan & Balasan Tiket Freshservice'
|
||||
_order = 'created_at asc, id asc'
|
||||
|
||||
ticket_id = fields.Many2one(
|
||||
'freshservice.ticket',
|
||||
string='Tiket',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True
|
||||
)
|
||||
|
||||
external_id = fields.Char(string='ID Percakapan')
|
||||
user_name = fields.Char(string='Pengirim')
|
||||
user_id_external = fields.Char(string='User ID Freshservice')
|
||||
|
||||
incoming = fields.Boolean(
|
||||
string='Pesan Masuk (Dari User)',
|
||||
help='True jika dikirim oleh Customer/Requester, False jika balasan dari Agen IT.'
|
||||
)
|
||||
|
||||
sender_type = fields.Selection([
|
||||
('agent', 'IT Support (Agent)'),
|
||||
('customer', 'User (Requester)'),
|
||||
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
|
||||
|
||||
body_text = fields.Text(string='Isi Pesan')
|
||||
created_at = fields.Datetime(string='Waktu Kirim')
|
||||
|
||||
def _compute_sender_type(self):
|
||||
"""Tentukan siapa yang kirim pesan:
|
||||
- 'customer' jika incoming=True (pesan dari user)
|
||||
- 'agent' jika incoming=False (balasan dari agen IT)
|
||||
"""
|
||||
for rec in self:
|
||||
rec.sender_type = 'customer' if rec.incoming else 'agent'
|
||||
430
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal file
430
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal file
@@ -0,0 +1,430 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi.
|
||||
Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan.
|
||||
"""
|
||||
from datetime import timedelta, datetime, time
|
||||
from odoo import models, fields, api
|
||||
|
||||
# ============================================================
|
||||
# KONSTANTA JAM KERJA OPERASIONAL (WIB / UTC+7)
|
||||
# Senin-Jumat, 08:30-17:30, Break 12:30-13:30 (8 jam efektif)
|
||||
# ============================================================
|
||||
_WORK_START = time(8, 30) # 08:30
|
||||
_WORK_END = time(17, 30) # 17:30
|
||||
_BREAK_START = time(12, 30) # 12:30
|
||||
_BREAK_END = time(13, 30) # 13:30
|
||||
_WORK_HOURS_PER_DAY = 8.0 # jam efektif per hari
|
||||
|
||||
|
||||
def _working_seconds_in_day(t_start, t_end):
|
||||
"""Hitung detik kerja efektif dalam satu hari antara t_start dan t_end (time objects).
|
||||
Mengecualikan break 12:30-13:30 dan di luar jam kerja 08:30-17:30.
|
||||
"""
|
||||
# Clamp ke jam kerja
|
||||
t_start = max(t_start, _WORK_START)
|
||||
t_end = min(t_end, _WORK_END)
|
||||
if t_end <= t_start:
|
||||
return 0.0
|
||||
|
||||
def to_sec(t):
|
||||
return t.hour * 3600 + t.minute * 60 + t.second
|
||||
|
||||
total = to_sec(t_end) - to_sec(t_start)
|
||||
|
||||
# Kurangi overlap dengan break 12:30-13:30
|
||||
break_s = max(t_start, _BREAK_START)
|
||||
break_e = min(t_end, _BREAK_END)
|
||||
if break_e > break_s:
|
||||
total -= (to_sec(break_e) - to_sec(break_s))
|
||||
|
||||
return max(0.0, total)
|
||||
|
||||
|
||||
# Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026)
|
||||
_INDONESIA_HOLIDAYS = {
|
||||
# 2024
|
||||
datetime(2024, 1, 1).date(), datetime(2024, 2, 8).date(), datetime(2024, 2, 9).date(),
|
||||
datetime(2024, 2, 10).date(), datetime(2024, 3, 11).date(), datetime(2024, 3, 12).date(),
|
||||
datetime(2024, 3, 29).date(), datetime(2024, 3, 31).date(), datetime(2024, 4, 8).date(),
|
||||
datetime(2024, 4, 9).date(), datetime(2024, 4, 10).date(), datetime(2024, 4, 11).date(),
|
||||
datetime(2024, 4, 12).date(), datetime(2024, 4, 15).date(), datetime(2024, 5, 1).date(),
|
||||
datetime(2024, 5, 9).date(), datetime(2024, 5, 10).date(), datetime(2024, 5, 23).date(),
|
||||
datetime(2024, 5, 24).date(), datetime(2024, 6, 1).date(), datetime(2024, 6, 17).date(),
|
||||
datetime(2024, 6, 18).date(), datetime(2024, 7, 7).date(), datetime(2024, 8, 17).date(),
|
||||
datetime(2024, 9, 16).date(), datetime(2024, 12, 25).date(), datetime(2024, 12, 26).date(),
|
||||
# 2025
|
||||
datetime(2025, 1, 1).date(), datetime(2025, 1, 27).date(), datetime(2025, 1, 28).date(),
|
||||
datetime(2025, 1, 29).date(), datetime(2025, 3, 28).date(), datetime(2025, 3, 29).date(),
|
||||
datetime(2025, 3, 31).date(), datetime(2025, 4, 1).date(), datetime(2025, 4, 2).date(),
|
||||
datetime(2025, 4, 3).date(), datetime(2025, 4, 4).date(), datetime(2025, 4, 7).date(),
|
||||
datetime(2025, 4, 18).date(), datetime(2025, 4, 20).date(), datetime(2025, 5, 1).date(),
|
||||
datetime(2025, 5, 12).date(), datetime(2025, 5, 13).date(), datetime(2025, 5, 29).date(),
|
||||
datetime(2025, 5, 30).date(), datetime(2025, 6, 1).date(), datetime(2025, 6, 6).date(),
|
||||
datetime(2025, 6, 9).date(), datetime(2025, 6, 27).date(), datetime(2025, 8, 17).date(),
|
||||
datetime(2025, 9, 5).date(), datetime(2025, 12, 25).date(), datetime(2025, 12, 26).date(),
|
||||
# 2026
|
||||
datetime(2026, 1, 1).date(), datetime(2026, 1, 16).date(), datetime(2026, 2, 17).date(),
|
||||
datetime(2026, 3, 19).date(), datetime(2026, 3, 20).date(), datetime(2026, 3, 21).date(),
|
||||
datetime(2026, 4, 3).date(), datetime(2026, 5, 1).date(), datetime(2026, 5, 14).date(),
|
||||
datetime(2026, 5, 31).date(), datetime(2026, 6, 1).date(), datetime(2026, 6, 27).date(),
|
||||
datetime(2026, 7, 16).date(), datetime(2026, 8, 17).date(), datetime(2026, 8, 25).date(),
|
||||
datetime(2026, 12, 25).date(),
|
||||
}
|
||||
|
||||
|
||||
def _holiday_dates(env, start_date, end_date):
|
||||
"""Return public leave dates combining Indonesian public holidays with Odoo's calendar leaves.
|
||||
|
||||
Prioritas Pengambilan Data:
|
||||
1. Model Master Data `freshservice.holiday` di database (hasil input manual atau impor Excel).
|
||||
2. Fallback ke konstanta `_INDONESIA_HOLIDAYS` jika tabel master data di DB masih kosong
|
||||
atau jika env Odoo tidak tersedia (misal unit test standalone).
|
||||
3. Menggabungkan dengan cuti dinamis dari `resource.calendar.leaves` jika tersedia.
|
||||
"""
|
||||
res = set()
|
||||
used_db = False
|
||||
|
||||
if env and hasattr(env, '__getitem__'):
|
||||
try:
|
||||
HolidayModel = env['freshservice.holiday'].sudo()
|
||||
# Cek apakah ada record hari libur aktif di database
|
||||
db_records = HolidayModel.search([
|
||||
('date', '>=', start_date),
|
||||
('date', '<=', end_date),
|
||||
('active', '=', True),
|
||||
])
|
||||
if db_records:
|
||||
res.update(rec.date for rec in db_records)
|
||||
used_db = True
|
||||
elif HolidayModel.search_count([('active', '=', True)]) > 0:
|
||||
# Database sudah memiliki data libur, namun tidak ada libur di rentang tanggal ini
|
||||
used_db = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not used_db:
|
||||
# Fallback ke konstanta _INDONESIA_HOLIDAYS jika DB belum terisi / tanpa koneksi DB
|
||||
res.update(d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date)
|
||||
|
||||
if env and hasattr(env, '__getitem__'):
|
||||
try:
|
||||
leaves = env['resource.calendar.leaves'].sudo().search([
|
||||
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
|
||||
('date_to', '>=', datetime.combine(start_date, time.min)),
|
||||
])
|
||||
for leave in leaves:
|
||||
if leave.date_from:
|
||||
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def compute_working_hours(env, start_dt, end_dt=None):
|
||||
"""Menghitung jam kerja efektif antara start_dt dan end_dt.
|
||||
|
||||
Aturan:
|
||||
- Jam kerja: 08:30 - 17:30 WIB (UTC+7)
|
||||
- Break: 12:30 - 13:30 (tidak dihitung)
|
||||
- Hari kerja: Senin - Jumat (Sabtu & Minggu dilewati)
|
||||
- Jam di DB diasumsikan UTC → dikonversi ke WIB dengan +7 jam
|
||||
|
||||
Returns:
|
||||
float: total jam kerja efektif (dibulatkan 2 desimal)
|
||||
"""
|
||||
if not start_dt:
|
||||
return 0.0
|
||||
|
||||
# Normalisasi ke datetime naive (UTC)
|
||||
def to_naive_utc(dt):
|
||||
if isinstance(dt, str):
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f'):
|
||||
try:
|
||||
return datetime.strptime(dt, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
if hasattr(dt, 'tzinfo') and dt.tzinfo is not None:
|
||||
return dt.replace(tzinfo=None) - dt.utcoffset()
|
||||
return dt
|
||||
|
||||
s_utc = to_naive_utc(start_dt)
|
||||
e_utc = to_naive_utc(end_dt) if end_dt else datetime.utcnow()
|
||||
|
||||
if not s_utc or not e_utc or e_utc <= s_utc:
|
||||
return 0.0
|
||||
|
||||
# Konversi ke WIB (UTC+7)
|
||||
s_wib = s_utc + timedelta(hours=7)
|
||||
e_wib = e_utc + timedelta(hours=7)
|
||||
|
||||
total_seconds = 0.0
|
||||
curr = s_wib.date()
|
||||
end_date = e_wib.date()
|
||||
holidays = _holiday_dates(env, curr, end_date)
|
||||
|
||||
while curr <= end_date:
|
||||
# Lewati Sabtu, Minggu, dan tanggal leave/libur nasional terdaftar
|
||||
if curr.weekday() >= 5 or curr in holidays:
|
||||
curr += timedelta(days=1)
|
||||
continue
|
||||
|
||||
# Tentukan window waktu untuk hari ini
|
||||
day_start = datetime.combine(curr, _WORK_START)
|
||||
day_end = datetime.combine(curr, _WORK_END)
|
||||
|
||||
# Clamp ke range s_wib - e_wib
|
||||
seg_start = max(datetime.combine(curr, time(0, 0)), s_wib)
|
||||
seg_end = min(datetime.combine(curr, time(23, 59, 59)), e_wib)
|
||||
|
||||
# Ambil time portion untuk _working_seconds_in_day
|
||||
t_s = max(seg_start, day_start)
|
||||
t_e = min(seg_end, day_end)
|
||||
|
||||
if t_e > t_s:
|
||||
total_seconds += _working_seconds_in_day(t_s.time(), t_e.time())
|
||||
|
||||
curr += timedelta(days=1)
|
||||
|
||||
return round(total_seconds / 3600.0, 2)
|
||||
|
||||
|
||||
def format_working_duration(hours_float, is_active=False):
|
||||
"""Format jam kerja efektif menjadi string yang mudah dibaca.
|
||||
Contoh: 12.0 → '12 Jam (1.5 Hari Kerja)'
|
||||
"""
|
||||
if not hours_float or hours_float <= 0:
|
||||
suffix = ' (Aktif)' if is_active else ''
|
||||
return f'0 Jam{suffix}'
|
||||
h = round(hours_float, 2)
|
||||
wd = round(h / _WORK_HOURS_PER_DAY, 2)
|
||||
# Tampilkan hari kerja: bulatkan ke .5 terdekat untuk keterbacaan
|
||||
wd_display = round(wd * 2) / 2
|
||||
suffix = ' (Aktif)' if is_active else ''
|
||||
return f'{h:.4g} Jam ({wd_display:.4g} Hari Kerja){suffix}'
|
||||
|
||||
|
||||
def compute_calendar_days(env, start_dt, end_dt=None, exclude_weekends=True):
|
||||
"""
|
||||
Menghitung selisih hari kerja (Working Days / Business Days) berdasarkan pergantian tanggal.
|
||||
Secara default mengecualikan hari Sabtu (weekday=5) dan Minggu (weekday=6),
|
||||
sehingga hari libur akhir pekan tidak terhitung sebagai waiting time / penalti SLA.
|
||||
|
||||
- exclude_weekends=True (default):
|
||||
* Dibuat Jumat, selesai Senin = 1 Hari Kerja (Sabtu & Minggu diskip).
|
||||
* Dibuat Jumat, selesai Jumat = 0 Hari Kerja.
|
||||
* Dibuat Sabtu, selesai Minggu = 0 Hari Kerja.
|
||||
- exclude_weekends=False:
|
||||
* Menghitung total hari kalender biasa murni.
|
||||
"""
|
||||
if not start_dt:
|
||||
return 0
|
||||
try:
|
||||
user_tz = env.context.get('tz') or env.user.tz or 'Asia/Jakarta'
|
||||
user_record = env.user.with_context(tz=user_tz)
|
||||
start_date = fields.Datetime.context_timestamp(user_record, start_dt).date()
|
||||
if end_dt:
|
||||
end_date = fields.Datetime.context_timestamp(user_record, end_dt).date()
|
||||
else:
|
||||
now_dt = fields.Datetime.now()
|
||||
end_date = fields.Datetime.context_timestamp(user_record, now_dt).date()
|
||||
|
||||
if end_date <= start_date:
|
||||
return 0
|
||||
|
||||
if not exclude_weekends:
|
||||
return (end_date - start_date).days
|
||||
|
||||
# Hitung hari kerja dengan melewati Sabtu (5) dan Minggu (6)
|
||||
work_days = 0
|
||||
curr = start_date + timedelta(days=1)
|
||||
while curr <= end_date:
|
||||
if curr.weekday() < 5: # 0=Senin ... 4=Jumat
|
||||
work_days += 1
|
||||
curr += timedelta(days=1)
|
||||
return work_days
|
||||
except Exception:
|
||||
s_date = start_dt.date() if hasattr(start_dt, 'date') else start_dt
|
||||
e_date = (end_dt or fields.Datetime.now()).date() if hasattr(end_dt or fields.Datetime.now(), 'date') else end_dt
|
||||
if e_date <= s_date:
|
||||
return 0
|
||||
if not exclude_weekends:
|
||||
return max(0, (e_date - s_date).days)
|
||||
work_days = 0
|
||||
curr = s_date + timedelta(days=1)
|
||||
while curr <= e_date:
|
||||
if curr.weekday() < 5:
|
||||
work_days += 1
|
||||
curr += timedelta(days=1)
|
||||
return work_days
|
||||
|
||||
def format_duration_days(days_int, is_active=False):
|
||||
"""Memformat durasi dalam bilangan bulat Hari."""
|
||||
days = max(0, int(days_int or 0))
|
||||
if is_active:
|
||||
return f"{days} Hari (Aktif)"
|
||||
return f"{days} Hari"
|
||||
|
||||
def format_duration_human(hours_float):
|
||||
"""Helper kompatibilitas yang sekarang menampilkan satuan hari."""
|
||||
if not hours_float or hours_float <= 0:
|
||||
return "0 Hari"
|
||||
days = int(round(hours_float / 24.0))
|
||||
return f"{days} Hari"
|
||||
|
||||
class FreshserviceTicketStageLog(models.Model):
|
||||
"""Satu baris = satu fase/status di tiket.
|
||||
Contoh: Open → In Progress → Pending User → Resolved.
|
||||
Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya,
|
||||
dan siapa yang bertanggung jawab (Agent/User/Third Party).
|
||||
"""
|
||||
_name = 'freshservice.ticket.stage.log'
|
||||
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
|
||||
_order = 'start_date asc, id asc'
|
||||
|
||||
ticket_id = fields.Many2one(
|
||||
'freshservice.ticket',
|
||||
string='Tiket',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True
|
||||
)
|
||||
|
||||
stage_name = fields.Char(string='Status / Stage', required=True)
|
||||
|
||||
responsibility = fields.Selection([
|
||||
('internal', 'Agent Active Work'),
|
||||
('it_hold', 'Agent Hold & Discussion'),
|
||||
('customer', 'Waiting on Requester'),
|
||||
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
|
||||
('needs_verification', 'Waiting on User Verification'),
|
||||
('scheduled_meeting', 'Scheduled Meeting (Shared)'),
|
||||
('third_party', 'Waiting on Third Party'),
|
||||
('resolved_idle', 'Resolved / Idle'),
|
||||
# Legacy values are retained for historical audit snapshots.
|
||||
('on_hold', 'On Hold (Legacy)'),
|
||||
('internal_discussion', 'Internal Discussion (Legacy)'),
|
||||
('external_activity', 'External Activity (Legacy)'),
|
||||
], string='Task Owner', required=True, default='internal', index=True)
|
||||
|
||||
effective_status = fields.Char(string='Effective Status', index=True)
|
||||
classification_basis = fields.Char(string='Classification Basis')
|
||||
needs_verification = fields.Boolean(string='User Verification Required')
|
||||
delay_cause = fields.Selection([
|
||||
('it', 'Agent'), ('user', 'User'), ('none', 'None'),
|
||||
], string='Delay Cause', default='none')
|
||||
automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True)
|
||||
override_responsibility = fields.Selection([
|
||||
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
|
||||
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
|
||||
('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
|
||||
('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'),
|
||||
], string='Task Owner Override')
|
||||
override_source = fields.Char(string='Override Source')
|
||||
override_reviewer = fields.Char(string='Override Reviewer')
|
||||
override_at = fields.Datetime(string='Override Date')
|
||||
|
||||
actor_name = fields.Char(string='Pelaku / Actor')
|
||||
actor_type = fields.Selection([
|
||||
('Agent', 'IT Support / Agent'),
|
||||
('Requester', 'User / Customer'),
|
||||
('System', 'System'),
|
||||
], string='Tipe Pelaku', default='Agent')
|
||||
|
||||
activity_content = fields.Text(string='Deskripsi Aktivitas')
|
||||
|
||||
start_date = fields.Datetime(string='Waktu Mulai', required=True)
|
||||
end_date = fields.Datetime(string='Waktu Selesai')
|
||||
|
||||
is_active = fields.Boolean(
|
||||
string='Sedang Berjalan',
|
||||
compute='_compute_is_active',
|
||||
store=True
|
||||
)
|
||||
|
||||
duration_days = fields.Integer(
|
||||
string='Durasi (Hari)',
|
||||
compute='_compute_duration',
|
||||
store=True
|
||||
)
|
||||
|
||||
duration_hours = fields.Float(
|
||||
string='Durasi (Jam)',
|
||||
compute='_compute_duration',
|
||||
store=True,
|
||||
digits=(12, 2)
|
||||
)
|
||||
|
||||
duration_display = fields.Char(
|
||||
string='Durasi',
|
||||
compute='_compute_duration',
|
||||
store=True
|
||||
)
|
||||
|
||||
working_hours = fields.Float(
|
||||
string='Jam Kerja',
|
||||
compute='_compute_working_metrics',
|
||||
store=True,
|
||||
digits=(12, 2)
|
||||
)
|
||||
|
||||
working_days_effective = fields.Float(
|
||||
string='Hari Kerja Efektif',
|
||||
compute='_compute_working_metrics',
|
||||
store=True,
|
||||
digits=(12, 2)
|
||||
)
|
||||
|
||||
working_duration_display = fields.Char(
|
||||
string='Durasi Jam Kerja',
|
||||
compute='_compute_working_metrics',
|
||||
store=True
|
||||
)
|
||||
|
||||
@api.depends('end_date')
|
||||
def _compute_is_active(self):
|
||||
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
|
||||
for rec in self:
|
||||
rec.is_active = not bool(rec.end_date)
|
||||
|
||||
@api.depends('start_date', 'end_date', 'is_active')
|
||||
def _compute_working_metrics(self):
|
||||
"""Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat)
|
||||
dan konversi ke hari kerja (1 hari = 8 jam).
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.start_date:
|
||||
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
|
||||
rec.working_hours = wh
|
||||
rec.working_days_effective = round(wh / 8.0, 2)
|
||||
rec.working_duration_display = format_working_duration(wh, is_active=rec.is_active)
|
||||
else:
|
||||
rec.working_hours = 0.0
|
||||
rec.working_days_effective = 0.0
|
||||
rec.working_duration_display = "-"
|
||||
|
||||
@api.depends('start_date', 'end_date')
|
||||
def _compute_duration(self):
|
||||
"""Hitung durasi dalam hari kalender dan jam total.
|
||||
Kalau stage masih aktif, hitung sampai waktu sekarang.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.start_date:
|
||||
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)
|
||||
rec.duration_days = days
|
||||
if rec.end_date:
|
||||
delta = rec.end_date - rec.start_date
|
||||
hours = max(0.0, delta.total_seconds() / 3600.0)
|
||||
rec.duration_hours = round(hours, 2)
|
||||
rec.duration_display = format_duration_days(days)
|
||||
else:
|
||||
# Sedang aktif berjalan sampai sekarang
|
||||
delta = fields.Datetime.now() - rec.start_date
|
||||
hours = max(0.0, delta.total_seconds() / 3600.0)
|
||||
rec.duration_hours = round(hours, 2)
|
||||
rec.duration_display = format_duration_days(days, is_active=True)
|
||||
else:
|
||||
rec.duration_days = 0
|
||||
rec.duration_hours = 0.0
|
||||
rec.duration_display = "-"
|
||||
1131
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal file
1131
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal file
File diff suppressed because it is too large
Load Diff
81
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal file
81
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Konfigurasi Settings untuk koneksi Freshservice.
|
||||
User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo.
|
||||
"""
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
|
||||
Bisa diakses dari menu Settings > Freshservice.
|
||||
"""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
freshservice_url = fields.Char(
|
||||
string='Freshservice URL',
|
||||
config_parameter='gmn_freshservice.freshservice_url',
|
||||
default='https://ptglobalmultimedianetwork.freshservice.com'
|
||||
)
|
||||
freshservice_api_key = fields.Char(
|
||||
string='Freshservice API Key',
|
||||
config_parameter='gmn_freshservice.freshservice_api_key'
|
||||
)
|
||||
freshservice_sync_limit = fields.Integer(
|
||||
string='Limit Sinkronisasi Tiket',
|
||||
config_parameter='gmn_freshservice.freshservice_sync_limit',
|
||||
default=200,
|
||||
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
|
||||
)
|
||||
freshservice_use_mock = fields.Boolean(
|
||||
string='Gunakan Mock Data Engine',
|
||||
config_parameter='gmn_freshservice.freshservice_use_mock',
|
||||
default=False,
|
||||
readonly=True,
|
||||
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
|
||||
)
|
||||
|
||||
def action_test_freshservice_connection(self):
|
||||
"""Tombol 'Test Connection' di halaman Settings.
|
||||
Coba panggil API Freshservice untuk cek apakah URL dan API Key valid.
|
||||
"""
|
||||
self.ensure_one()
|
||||
url = (self.freshservice_url or "").rstrip("/")
|
||||
if not url:
|
||||
raise UserError("URL Freshservice tidak boleh kosong!")
|
||||
|
||||
if self.freshservice_use_mock:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Mode Mock Aktif',
|
||||
'message': 'Engine simulasi aktif. Anda dapat langsung menarik tiket contoh tanpa API Key!',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
if not self.freshservice_api_key:
|
||||
raise UserError("API Key wajib diisi untuk pengujian Live API!")
|
||||
|
||||
try:
|
||||
auth = (self.freshservice_api_key, 'X')
|
||||
headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
|
||||
res = requests.get(f"{url}/api/v2/tickets?per_page=1", auth=auth, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Koneksi Berhasil!',
|
||||
'message': f'Berhasil terhubung ke Live Freshservice API ({url}).',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise UserError(f"Gagal terhubung (HTTP {res.status_code}): {res.text}")
|
||||
except Exception as e:
|
||||
raise UserError(f"Terjadi kesalahan koneksi: {str(e)}")
|
||||
39
gmn_freshservice/security/gmn_freshservice_security_v01.xml
Normal file
39
gmn_freshservice/security/gmn_freshservice_security_v01.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================
|
||||
MODULE CATEGORY: Freshservice
|
||||
Muncul sebagai section header di tab Access Rights form User.
|
||||
============================================================ -->
|
||||
<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>
|
||||
|
||||
<!-- ============================================================
|
||||
GROUP 1: Viewer / Agent IT (Read-Only)
|
||||
- category_id menghubungkan group ke category Freshservice
|
||||
- sequence 10: tampil pertama di dropdown
|
||||
============================================================ -->
|
||||
<record id="group_freshservice_user" model="res.groups">
|
||||
<field name="name">Viewer / Agent IT</field>
|
||||
<field name="category_id" ref="module_category_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)
|
||||
- category_id menghubungkan group ke category Freshservice
|
||||
- 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="category_id" ref="module_category_freshservice"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_freshservice_user'))]"/>
|
||||
<field name="users" 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>
|
||||
12
gmn_freshservice/security/ir.model.access.csv
Normal file
12
gmn_freshservice/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_freshservice_ticket_user,freshservice.ticket.user,model_freshservice_ticket,gmn_freshservice.group_freshservice_user,1,0,0,0
|
||||
access_freshservice_ticket_manager,freshservice.ticket.manager,model_freshservice_ticket,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
access_freshservice_ticket_stage_log_user,freshservice.ticket.stage.log.user,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_user,1,0,0,0
|
||||
access_freshservice_ticket_stage_log_manager,freshservice.ticket.stage.log.manager,model_freshservice_ticket_stage_log,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
access_freshservice_ticket_conversation_user,freshservice.ticket.conversation.user,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_user,1,1,1,1
|
||||
access_freshservice_ticket_conversation_manager,freshservice.ticket.conversation.manager,model_freshservice_ticket_conversation,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
access_freshservice_holiday_user,freshservice.holiday.user,model_freshservice_holiday,gmn_freshservice.group_freshservice_user,1,0,0,0
|
||||
access_freshservice_holiday_manager,freshservice.holiday.manager,model_freshservice_holiday,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
access_freshservice_holiday_import_wizard_manager,freshservice.holiday.import.wizard.manager,model_freshservice_holiday_import_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
access_freshservice_ticket_bottleneck_wizard_user,freshservice.ticket.bottleneck.wizard.user,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_user,1,1,1,1
|
||||
access_freshservice_ticket_bottleneck_wizard_manager,freshservice.ticket.bottleneck.wizard.manager,model_freshservice_ticket_bottleneck_wizard,gmn_freshservice.group_freshservice_manager,1,1,1,1
|
||||
|
3
gmn_freshservice/services/__init__.py
Normal file
3
gmn_freshservice/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package services: berisi client untuk koneksi ke Freshservice API."""
|
||||
from . import gmn_freshservice_client_v01
|
||||
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
652
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
652
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
@@ -0,0 +1,652 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client untuk koneksi ke Freshservice REST API v2.
|
||||
Bisa jalan dalam 2 mode: Live (panggil API beneran) atau Mock (data dummy untuk testing).
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import requests
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class FreshserviceClient:
|
||||
"""
|
||||
Client adapter untuk integrasi Freshservice API v2.
|
||||
Mendukung dua mode:
|
||||
1. Mock Mode (Format data 100% identik dengan dokumentasi resmi Freshservice API)
|
||||
2. Live API Mode (Direct HTTP call ke endpoint perusahaan)
|
||||
"""
|
||||
|
||||
STATUS_MAP = {
|
||||
2: 'open',
|
||||
3: 'pending',
|
||||
4: 'resolved',
|
||||
5: 'closed',
|
||||
}
|
||||
|
||||
PRIORITY_MAP = {
|
||||
1: 'low',
|
||||
2: 'medium',
|
||||
3: 'high',
|
||||
4: 'urgent',
|
||||
}
|
||||
|
||||
_agent_cache = {}
|
||||
_dept_cache = {}
|
||||
|
||||
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
|
||||
"""Inisialisasi koneksi ke Freshservice.
|
||||
- base_url: URL instance Freshservice (misal: https://company.freshservice.com)
|
||||
- api_key: token API untuk otentikasi
|
||||
- use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet
|
||||
"""
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.api_key = api_key or ""
|
||||
self.use_mock = False
|
||||
|
||||
def get_agent_name(self, agent_id):
|
||||
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||
if not agent_id or self.use_mock:
|
||||
return ""
|
||||
if agent_id in self._agent_cache:
|
||||
return self._agent_cache[agent_id]
|
||||
|
||||
try:
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
url = f"{self.base_url}/api/v2/agents/{agent_id}"
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
agent = res.json().get("agent", {})
|
||||
first_name = agent.get("first_name") or ""
|
||||
last_name = agent.get("last_name") or ""
|
||||
full_name = f"{first_name} {last_name}".strip() or agent.get("email") or str(agent_id)
|
||||
self._agent_cache[agent_id] = full_name
|
||||
return full_name
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal fetch agent %s dari Freshservice: %s", agent_id, e)
|
||||
|
||||
self._agent_cache[agent_id] = ""
|
||||
return ""
|
||||
|
||||
def get_department_name(self, dept_id):
|
||||
"""Ambil nama departemen Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||
if not dept_id or self.use_mock:
|
||||
return ""
|
||||
if dept_id in self._dept_cache:
|
||||
return self._dept_cache[dept_id]
|
||||
|
||||
try:
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
url = f"{self.base_url}/api/v2/departments/{dept_id}"
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
dept = res.json().get("department", {})
|
||||
name = dept.get("name") or ""
|
||||
self._dept_cache[dept_id] = name
|
||||
return name
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal fetch department %s dari Freshservice: %s", dept_id, e)
|
||||
|
||||
self._dept_cache[dept_id] = ""
|
||||
return ""
|
||||
|
||||
def fetch_ticket_full_data(self, ticket_id):
|
||||
"""
|
||||
Mengambil paket lengkap data tiket:
|
||||
- Ticket Details + SLA Stats
|
||||
- Audit Trail Activities
|
||||
- Conversations (Replies & Notes)
|
||||
"""
|
||||
if self.use_mock:
|
||||
_logger.info("Mengambil data tiket Freshservice via MOCK ENGINE untuk ID: %s", ticket_id)
|
||||
return self._get_mock_payload(ticket_id)
|
||||
else:
|
||||
_logger.info("Menghubungi LIVE Freshservice API: %s untuk ID: %s", self.base_url, ticket_id)
|
||||
return self._get_live_payload(ticket_id)
|
||||
|
||||
def fetch_recent_tickets(self, limit=200, tiket_id=None):
|
||||
"""
|
||||
Mengambil daftar tiket terbaru dari Freshservice API (default 200 tiket terbaru).
|
||||
Freshservice API v2 membatasi per_page maksimum 100 tiket per request,
|
||||
sehingga dilakukan paginasi (page 1, page 2, dst) secara cepat hingga mencapai limit.
|
||||
Hanya mengambil ringkasan tiket dan status (tanpa membebani API dengan detail activities).
|
||||
"""
|
||||
if self.use_mock:
|
||||
return self._get_mock_recent_tickets(limit=limit)
|
||||
|
||||
if not self.base_url or not self.api_key:
|
||||
raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!")
|
||||
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
tickets = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
while len(tickets) < limit:
|
||||
fetch_count = min(per_page, limit - len(tickets))
|
||||
url = f"{self.base_url}/api/v2/tickets?order_by=created_at&order_type=desc&per_page={fetch_count}&page={page}&include=requester,stats"
|
||||
try:
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=25)
|
||||
if res.status_code == 200:
|
||||
batch = res.json().get("tickets", [])
|
||||
if not batch:
|
||||
break
|
||||
|
||||
# Resolusi info pemohon, agen responder, dan departemen
|
||||
for t in batch:
|
||||
req = t.get('requester') or {}
|
||||
t['requester_name'] = req.get('name') or t.get('requester_name') or ''
|
||||
t['requester_email'] = req.get('email') or t.get('requester_email') or t.get('email') or ''
|
||||
|
||||
resp_id = t.get('responder_id')
|
||||
if resp_id:
|
||||
t['responder_name'] = self.get_agent_name(resp_id)
|
||||
|
||||
dept_id = t.get('department_id')
|
||||
if dept_id:
|
||||
t['department_name'] = self.get_department_name(dept_id)
|
||||
elif t.get('custom_fields', {}).get('company_information_for_owner_of_ticket'):
|
||||
t['department_name'] = t['custom_fields']['company_information_for_owner_of_ticket']
|
||||
|
||||
tickets.extend(batch)
|
||||
if len(batch) < fetch_count:
|
||||
# Data di Freshservice sudah habis
|
||||
break
|
||||
page += 1
|
||||
else:
|
||||
_logger.warning("Gagal fetch recent tickets halaman %s (HTTP %s): %s", page, res.status_code, res.text)
|
||||
break
|
||||
except Exception as e:
|
||||
_logger.error("Error fetching recent tickets from Freshservice: %s", e)
|
||||
break
|
||||
|
||||
return tickets[:limit]
|
||||
|
||||
def _get_mock_recent_tickets(self, limit=200):
|
||||
"""Mock data ringkasan tiket untuk pengujian cepat tanpa membebani network"""
|
||||
samples = [
|
||||
{
|
||||
"id": 129,
|
||||
"subject": "Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #129]",
|
||||
"status": 4,
|
||||
"priority": 3,
|
||||
"requester_name": "Ahmad Dani (User)",
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"department_name": "Finance & Accounting",
|
||||
"created_at": "2026-10-10T08:30:00Z",
|
||||
"due_by": "2026-10-12T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-10T08:52:00Z",
|
||||
"resolved_at": "2026-10-11T16:30:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9991,
|
||||
"subject": "User Akses untuk modul report Account Receivables",
|
||||
"status": 2,
|
||||
"priority": 1,
|
||||
"requester_name": "Henny Gunawan",
|
||||
"responder_name": "Jonathan Tan",
|
||||
"department_name": "Accounting",
|
||||
"created_at": "2026-10-12T02:45:00Z",
|
||||
"due_by": "2026-10-14T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-12T03:15:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9966,
|
||||
"subject": "Email gom@parahyangangolf.com - Your Certificate has Expired",
|
||||
"status": 4,
|
||||
"priority": 1,
|
||||
"requester_name": "Rohmat tulloh",
|
||||
"responder_name": "Prabowo Bowo",
|
||||
"department_name": "IT Infrastructure",
|
||||
"created_at": "2026-10-09T06:27:00Z",
|
||||
"due_by": "2026-10-11T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-09T07:10:00Z",
|
||||
"resolved_at": "2026-10-09T09:00:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9950,
|
||||
"subject": "Permintaan Install Ulang Windows Laptop Sales",
|
||||
"status": 3,
|
||||
"priority": 2,
|
||||
"requester_name": "Siti Nurhaliza",
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"department_name": "Sales & Marketing",
|
||||
"created_at": "2026-10-11T09:15:00Z",
|
||||
"due_by": "2026-10-13T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-11T10:00:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9942,
|
||||
"subject": "Reset Password Akun VPN Kantor Pusat",
|
||||
"status": 5,
|
||||
"priority": 2,
|
||||
"requester_name": "Rudi Hermawan",
|
||||
"responder_name": "Prabowo Bowo",
|
||||
"department_name": "Human Resources",
|
||||
"created_at": "2026-10-08T11:00:00Z",
|
||||
"due_by": "2026-10-09T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-08T11:15:00Z",
|
||||
"resolved_at": "2026-10-08T11:45:00Z",
|
||||
}
|
||||
},
|
||||
]
|
||||
return samples[:limit]
|
||||
|
||||
# def _get_live_payload(self, ticket_id):
|
||||
# if not self.base_url or not self.api_key:
|
||||
# raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!")
|
||||
|
||||
# auth = (self.api_key, "X")
|
||||
# headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
# # 1. Ticket details & stats with requester
|
||||
# url_ticket = f"{self.base_url}/api/v2/tickets/{ticket_id}?include=requester,stats"
|
||||
# res_ticket = requests.get(url_ticket, auth=auth, headers=headers, timeout=20)
|
||||
# if res_ticket.status_code != 200:
|
||||
# raise UserError(f"Gagal mengambil tiket dari Freshservice (HTTP {res_ticket.status_code}): {res_ticket.text}")
|
||||
# ticket_json = res_ticket.json().get("ticket", {})
|
||||
|
||||
# # Resolusi data pemohon (requester), agen (responder), dan departemen
|
||||
# req = ticket_json.get('requester') or {}
|
||||
# ticket_json['requester_name'] = req.get('name') or ticket_json.get('requester_name') or ''
|
||||
# ticket_json['requester_email'] = req.get('email') or ticket_json.get('requester_email') or ticket_json.get('email') or ''
|
||||
|
||||
# resp_id = ticket_json.get('responder_id')
|
||||
# if resp_id:
|
||||
# ticket_json['responder_name'] = self.get_agent_name(resp_id)
|
||||
|
||||
# dept_id = ticket_json.get('department_id')
|
||||
# if dept_id:
|
||||
# ticket_json['department_name'] = self.get_department_name(dept_id)
|
||||
# elif ticket_json.get('custom_fields', {}).get('company_information_for_owner_of_ticket'):
|
||||
# ticket_json['department_name'] = ticket_json['custom_fields']['company_information_for_owner_of_ticket']
|
||||
|
||||
# # 2. Activities (Audit Trail) with pagination support
|
||||
# url_activities = f"{self.base_url}/api/v2/tickets/{ticket_id}/activities"
|
||||
# activities_json = []
|
||||
# page_count = 0
|
||||
# while url_activities and page_count < 10:
|
||||
# page_count += 1
|
||||
# try:
|
||||
# res_act = requests.get(url_activities, auth=auth, headers=headers, timeout=20)
|
||||
# if res_act.status_code == 200:
|
||||
# act_data = res_act.json()
|
||||
# activities_json.extend(act_data.get("activities", []))
|
||||
# url_activities = act_data.get("next_page_url")
|
||||
# else:
|
||||
# _logger.warning("Gagal fetch activities Freshservice (HTTP %s): %s", res_act.status_code, res_act.text)
|
||||
# break
|
||||
# except Exception as e:
|
||||
# _logger.warning("Exception saat fetch activities halaman %s: %s", page_count, e)
|
||||
# break
|
||||
|
||||
# # 3. Conversations
|
||||
# url_conv = f"{self.base_url}/api/v2/tickets/{ticket_id}/conversations"
|
||||
# res_conv = requests.get(url_conv, auth=auth, headers=headers, timeout=20)
|
||||
# conversations_json = res_conv.json().get("conversations", []) if res_conv.status_code == 200 else []
|
||||
|
||||
# return {
|
||||
# "ticket": ticket_json,
|
||||
# "activities": activities_json,
|
||||
# "conversations": conversations_json,
|
||||
# }
|
||||
def _get_live_payload(self, ticket_id):
|
||||
"""Ambil data lengkap tiket dari server live Freshservice via REST API.
|
||||
Memanggil beberapa endpoint:
|
||||
1. /api/v2/tickets/{id}?include=stats,requester - info tiket & metrik
|
||||
2. /api/v2/tickets/{id}/activities - audit trail/riwayat perubahan
|
||||
3. /api/v2/tickets/{id}/conversations - riwayat pesan/chat
|
||||
Mengembalikan dictionary berisi ketiga data di atas.
|
||||
"""
|
||||
if not self.base_url or not self.api_key:
|
||||
raise UserError(
|
||||
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!"
|
||||
)
|
||||
|
||||
auth = (self.api_key, "X")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# 1. Ticket details & stats with requester
|
||||
url_ticket = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}?include=requester,stats"
|
||||
)
|
||||
|
||||
res_ticket = requests.get(
|
||||
url_ticket,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_ticket.status_code != 200:
|
||||
raise UserError(
|
||||
f"Gagal mengambil tiket dari Freshservice "
|
||||
f"(HTTP {res_ticket.status_code}): {res_ticket.text}"
|
||||
)
|
||||
|
||||
ticket_json = res_ticket.json().get("ticket", {})
|
||||
|
||||
# Resolusi data pemohon (requester), agen (responder), dan departemen
|
||||
req = ticket_json.get("requester") or {}
|
||||
|
||||
ticket_json["requester_name"] = (
|
||||
req.get("name")
|
||||
or ticket_json.get("requester_name")
|
||||
or ""
|
||||
)
|
||||
|
||||
ticket_json["requester_email"] = (
|
||||
req.get("email")
|
||||
or ticket_json.get("requester_email")
|
||||
or ticket_json.get("email")
|
||||
or ""
|
||||
)
|
||||
|
||||
resp_id = ticket_json.get("responder_id")
|
||||
if resp_id:
|
||||
ticket_json["responder_name"] = self.get_agent_name(resp_id)
|
||||
|
||||
dept_id = ticket_json.get("department_id")
|
||||
if dept_id:
|
||||
ticket_json["department_name"] = self.get_department_name(dept_id)
|
||||
elif ticket_json.get("custom_fields", {}).get(
|
||||
"company_information_for_owner_of_ticket"
|
||||
):
|
||||
ticket_json["department_name"] = ticket_json["custom_fields"][
|
||||
"company_information_for_owner_of_ticket"
|
||||
]
|
||||
|
||||
# 2. Activities (Audit Trail)
|
||||
# Fetch seluruh halaman sampai next_page_url kosong
|
||||
url_activities = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}/activities"
|
||||
)
|
||||
|
||||
activities_json = []
|
||||
visited_urls = set()
|
||||
|
||||
while url_activities:
|
||||
# Mencegah infinite loop jika URL pagination berulang
|
||||
if url_activities in visited_urls:
|
||||
_logger.warning(
|
||||
"Pagination activities dihentikan karena URL berulang: %s",
|
||||
url_activities,
|
||||
)
|
||||
break
|
||||
|
||||
visited_urls.add(url_activities)
|
||||
|
||||
try:
|
||||
res_act = requests.get(
|
||||
url_activities,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_act.status_code != 200:
|
||||
_logger.warning(
|
||||
"Gagal fetch activities Freshservice (HTTP %s): %s",
|
||||
res_act.status_code,
|
||||
res_act.text,
|
||||
)
|
||||
break
|
||||
|
||||
act_data = res_act.json()
|
||||
|
||||
# Tambahkan activities dari halaman saat ini
|
||||
activities_json.extend(
|
||||
act_data.get("activities", [])
|
||||
)
|
||||
|
||||
# Lanjut sampai halaman terakhir
|
||||
url_activities = act_data.get("next_page_url")
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Exception saat fetch activities Freshservice: %s",
|
||||
e,
|
||||
)
|
||||
break
|
||||
|
||||
_logger.info(
|
||||
"Freshservice ticket %s: berhasil mengambil %s activities",
|
||||
ticket_id,
|
||||
len(activities_json),
|
||||
)
|
||||
|
||||
# 3. Conversations
|
||||
url_conv = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}/conversations"
|
||||
)
|
||||
|
||||
conversations_json = []
|
||||
|
||||
try:
|
||||
res_conv = requests.get(
|
||||
url_conv,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_conv.status_code == 200:
|
||||
conversations_json = res_conv.json().get(
|
||||
"conversations", []
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Gagal fetch conversations Freshservice "
|
||||
"(HTTP %s): %s",
|
||||
res_conv.status_code,
|
||||
res_conv.text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Exception saat fetch conversations Freshservice: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
return {
|
||||
"ticket": ticket_json,
|
||||
"activities": activities_json,
|
||||
"conversations": conversations_json,
|
||||
}
|
||||
|
||||
def _get_mock_payload(self, ticket_id):
|
||||
"""
|
||||
Data Mock standar enterprise sesuai dokumentasi Freshservice API v2.
|
||||
Merefleksikan siklus tiket riil:
|
||||
- Open (08:30)
|
||||
- First Response oleh Agent (08:52) -> jeda 22 menit
|
||||
- Status Pending User (09:30) -> menunggu klarifikasi user
|
||||
- User Balas (Keesokan hari 14:00) -> 28.5 jam di user
|
||||
- Status In Progress (14:00) -> 2.5 jam di internal
|
||||
- Status Resolved (16:30)
|
||||
"""
|
||||
str_id = str(ticket_id or "129")
|
||||
|
||||
return {
|
||||
"ticket": {
|
||||
"id": int(str_id) if str_id.isdigit() else 129,
|
||||
"subject": f"Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #{str_id}]",
|
||||
"description_text": "Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.",
|
||||
"status": 4, # 4 = Resolved
|
||||
"priority": 3, # 3 = High
|
||||
"source": 2, # Portal
|
||||
"requester_id": 19000123456,
|
||||
"requester_name": "Ahmad Dani (User)",
|
||||
"requester_email": "ahmad.dani@lyman.co.id",
|
||||
"responder_id": 19000234567,
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"responder_email": "budi.santoso@lyman.co.id",
|
||||
"department_name": "Finance & Accounting",
|
||||
"created_at": "2026-10-10T08:30:00Z",
|
||||
"updated_at": "2026-10-11T16:30:00Z",
|
||||
"due_by": "2026-10-12T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-10T08:52:00Z",
|
||||
"resolved_at": "2026-10-11T16:30:00Z",
|
||||
"closed_at": None,
|
||||
"pending_since": "2026-10-10T09:30:00Z",
|
||||
}
|
||||
},
|
||||
"activities": [
|
||||
{
|
||||
"id": 101,
|
||||
"actor": {
|
||||
"id": 19000123456,
|
||||
"name": "Ahmad Dani (User)",
|
||||
"is_agent": False
|
||||
},
|
||||
"actor_id": 19000123456,
|
||||
"actor_type": "Requester",
|
||||
"actor_name": "Ahmad Dani (User)",
|
||||
"content": "Tiket dibuat via Web Portal dengan status Open",
|
||||
"stage_name": "Open",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-10T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.",
|
||||
"stage_name": "In Progress",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-10T08:52:00Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.",
|
||||
"stage_name": "Pending User",
|
||||
"responsibility": "customer",
|
||||
"created_at": "2026-10-10T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"actor": {
|
||||
"id": 19000123456,
|
||||
"name": "Ahmad Dani (User)",
|
||||
"is_agent": False
|
||||
},
|
||||
"actor_id": 19000123456,
|
||||
"actor_type": "Requester",
|
||||
"actor_name": "Ahmad Dani (User)",
|
||||
"content": "User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.",
|
||||
"stage_name": "In Progress",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-11T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.",
|
||||
"stage_name": "Resolved",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-11T16:30:00Z"
|
||||
}
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": 501,
|
||||
"body_text": "Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-10T08:52:00Z"
|
||||
},
|
||||
{
|
||||
"id": 502,
|
||||
"body_text": "Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-10T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 503,
|
||||
"body_text": "Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.",
|
||||
"incoming": True,
|
||||
"user_id": 19000123456,
|
||||
"user_name": "Ahmad Dani (User)",
|
||||
"created_at": "2026-10-11T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 504,
|
||||
"body_text": "Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-11T16:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_datetime(iso_str):
|
||||
"""Konversi timestamp API ke string datetime UTC yang diterima Odoo.
|
||||
|
||||
API dapat mengirim ISO string maupun objek ``datetime``. Keduanya
|
||||
dinormalisasi ke format yang sama agar tidak terjadi perbandingan
|
||||
``str`` dengan ``datetime`` saat audit trail direkonstruksi.
|
||||
"""
|
||||
if not iso_str:
|
||||
return False
|
||||
try:
|
||||
if isinstance(iso_str, datetime.datetime):
|
||||
dt = iso_str
|
||||
elif isinstance(iso_str, str):
|
||||
clean = iso_str.strip().replace("Z", "+00:00")
|
||||
dt = datetime.datetime.fromisoformat(clean)
|
||||
else:
|
||||
_logger.warning("Tipe datetime tidak didukung: %r", type(iso_str))
|
||||
return False
|
||||
|
||||
# Konversi ke naive UTC untuk Odoo.
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal parse datetime '%s': %s", iso_str, e)
|
||||
return False
|
||||
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>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- LIST VIEW STAGE LOGS -->
|
||||
<record id="view_freshservice_ticket_stage_log_tree" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.stage.log.tree</field>
|
||||
<field name="model">freshservice.ticket.stage.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Audit Trail & Status Duration"
|
||||
decoration-danger="responsibility == 'internal' and is_active"
|
||||
decoration-warning="responsibility == 'customer' and is_active"
|
||||
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||
<field name="ticket_id"/>
|
||||
<field name="stage_name" widget="badge"/>
|
||||
<field name="responsibility"
|
||||
widget="badge"
|
||||
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
|
||||
decoration-warning="responsibility == 'customer'"
|
||||
decoration-info="responsibility == 'scheduled_meeting'"
|
||||
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
|
||||
<field name="actor_name"/>
|
||||
<field name="actor_type"/>
|
||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||
<field name="end_date" string="Waktu Selesai (UTC)"/>
|
||||
<field name="working_duration_display" string="Jam Kerja (08:30-17:30)"/>
|
||||
<field name="working_days_effective" string="Hari Kerja Efektif"/>
|
||||
<field name="duration_display" string="Durasi Kalender"/>
|
||||
<field name="is_active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- FORM VIEW STAGE LOGS -->
|
||||
<record id="view_freshservice_ticket_stage_log_form" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.stage.log.form</field>
|
||||
<field name="model">freshservice.ticket.stage.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Detail Audit Trail">
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Informasi Status & Tanggung Jawab">
|
||||
<field name="ticket_id" readonly="1"/>
|
||||
<field name="stage_name" readonly="1"/>
|
||||
<field name="effective_status" readonly="1"/>
|
||||
<field name="responsibility" readonly="1" string="Effective Responsibility"/>
|
||||
<field name="automatic_responsibility" readonly="1"/>
|
||||
<field name="actor_name" readonly="1"/>
|
||||
<field name="actor_type" readonly="1"/>
|
||||
</group>
|
||||
<group string="Durasi Kerja Operasional (08:30-17:30 WIB)">
|
||||
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
||||
<field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/>
|
||||
<field name="working_duration_display" readonly="1" string="Jam Kerja Efektif"/>
|
||||
<field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
|
||||
<field name="duration_display" readonly="1" string="Durasi Kalender"/>
|
||||
<field name="is_active" readonly="1"/>
|
||||
</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>
|
||||
<page string="Deskripsi Aktivitas">
|
||||
<field name="activity_content" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- SEARCH VIEW STAGE LOGS -->
|
||||
<record id="view_freshservice_ticket_stage_log_search" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.stage.log.search</field>
|
||||
<field name="model">freshservice.ticket.stage.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Activity Logs">
|
||||
<field name="ticket_id"/>
|
||||
<field name="stage_name"/>
|
||||
<field name="actor_name"/>
|
||||
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
|
||||
<filter name="filter_customer" string="Waiting on User (Requester)" domain="[('responsibility', '=', 'customer')]"/>
|
||||
<filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/>
|
||||
<filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/>
|
||||
<filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/>
|
||||
<filter name="filter_scheduled_meeting" string="Scheduled Meeting (Waiting Together)" domain="[('responsibility', '=', 'scheduled_meeting')]"/>
|
||||
<filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/>
|
||||
<filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/>
|
||||
<filter name="filter_sla_paused" string="All SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
|
||||
<filter name="filter_active" string="In Progress (Active)" domain="[('is_active', '=', True)]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
||||
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'responsibility'}"/>
|
||||
<filter name="group_by_stage" string="Status / Stage" context="{'group_by': 'stage_name'}"/>
|
||||
<filter name="group_by_actor" string="Pelaku (Actor)" context="{'group_by': 'actor_name'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ACTION STAGE LOGS -->
|
||||
<record id="action_freshservice_ticket_stage_log" model="ir.actions.act_window">
|
||||
<field name="name">Audit Trail Riwayat Status</field>
|
||||
<field name="res_model">freshservice.ticket.stage.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_freshservice_ticket_stage_log_search"/>
|
||||
<field name="context">{'search_default_group_by_ticket': 1}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
337
gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml
Normal file
337
gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml
Normal file
@@ -0,0 +1,337 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- FORM VIEW -->
|
||||
<record id="view_freshservice_ticket_form" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.form</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Freshservice Ticket Audit Trail">
|
||||
<header>
|
||||
<button name="action_sync_from_freshservice"
|
||||
type="object"
|
||||
string="Sync Freshservice Data"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
groups="gmn_freshservice.group_freshservice_manager"/>
|
||||
<button name="action_open_bottleneck_wizard"
|
||||
type="object"
|
||||
string="Bottleneck Report (Excel)"
|
||||
class="btn-outline-success"
|
||||
icon="fa-file-excel-o"/>
|
||||
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="is_detail_synced" invisible="1"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Ticket Subject / Issue"/>
|
||||
<h1>
|
||||
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
||||
</h1>
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<span class="badge text-bg-secondary fs-6">ID Freshservice: #<field name="external_ticket_id" class="oe_inline"/></span>
|
||||
<field name="current_responsibility" widget="badge"
|
||||
decoration-danger="current_responsibility == 'internal'"
|
||||
decoration-warning="current_responsibility == 'customer'"
|
||||
decoration-info="current_responsibility == 'scheduled_meeting'"
|
||||
decoration-muted="current_responsibility == 'third_party'"
|
||||
decoration-success="current_responsibility == 'closed'"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI METRIC CARDS SECTION -->
|
||||
<div class="row mt-4 mb-4">
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-primary">
|
||||
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
||||
<h4 class="mb-0 text-primary mt-1"><field name="first_response_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Oleh: <field name="first_responder_name" readonly="1"/></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-danger">
|
||||
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
||||
<h4 class="mb-0 text-danger mt-1"><field name="total_agent_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Waiting on Us</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-warning">
|
||||
<small class="text-muted fw-bold text-uppercase">User</small>
|
||||
<h4 class="mb-0 text-warning mt-1"><field name="total_customer_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Waiting on Customer</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-info">
|
||||
<small class="text-muted fw-bold text-uppercase">Scheduled Meeting</small>
|
||||
<h4 class="mb-0 text-info mt-1"><field name="shared_wait_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Waiting Together</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-secondary">
|
||||
<small class="text-muted fw-bold text-uppercase">Third Party</small>
|
||||
<h4 class="mb-0 text-secondary mt-1"><field name="third_party_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Waiting on Vendor</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card text-center p-3 shadow-sm border-success">
|
||||
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
||||
<h4 class="mb-0 text-success mt-1"><field name="resolution_time_display" readonly="1"/></h4>
|
||||
<small class="text-secondary mt-1">Total Resolution</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="Informasi Pelapor & Penugasan">
|
||||
<field name="requester_name"/>
|
||||
<field name="requester_email"/>
|
||||
<field name="responder_name"/>
|
||||
<field name="department_name"/>
|
||||
</group>
|
||||
<group string="Parameter Tiket">
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="ticket_created_at" readonly="1"/>
|
||||
<field name="due_by" readonly="1"/>
|
||||
<field name="is_mock_data" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Detail Flow & Durasi Status" name="audit_trail">
|
||||
<div class="alert alert-info py-2" role="alert">
|
||||
<strong>Log Transparansi SLA:</strong> Tabel ini menghitung secara objektif berapa lama tiket berada di tangan agen internal vs menunggu konfirmasi user di setiap pergantian status.
|
||||
</div>
|
||||
<field name="stage_log_ids" readonly="1">
|
||||
<list decoration-danger="responsibility == 'internal' and is_active"
|
||||
decoration-warning="responsibility == 'customer' and is_active"
|
||||
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
|
||||
<field name="stage_name" widget="badge"/>
|
||||
<field name="responsibility" widget="badge"
|
||||
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
|
||||
decoration-warning="responsibility == 'customer'"
|
||||
decoration-info="responsibility == 'scheduled_meeting'"
|
||||
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
|
||||
<field name="actor_name"/>
|
||||
<field name="actor_type"/>
|
||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||
<field name="end_date" string="Waktu Selesai (UTC)"/>
|
||||
<field name="working_duration_display" string="Jam Kerja (08:30-17:30)"/>
|
||||
<field name="working_days_effective" string="Hari Kerja Efektif"/>
|
||||
<field name="activity_content"/>
|
||||
<field name="is_active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="Percakapan & Balasan" name="conversations">
|
||||
<field name="conversation_ids" readonly="1">
|
||||
<list>
|
||||
<field name="created_at"/>
|
||||
<field name="sender_type" widget="badge"
|
||||
decoration-info="sender_type == 'agent'"
|
||||
decoration-success="sender_type == 'customer'"/>
|
||||
<field name="user_name"/>
|
||||
<field name="body_text"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="Deskripsi Masalah" name="description">
|
||||
<field name="description"/>
|
||||
</page>
|
||||
|
||||
<page string="Timestamp Teknis" name="technical_timestamps">
|
||||
<group>
|
||||
<group string="Waktu Kejadian">
|
||||
<field name="ticket_created_at" readonly="1"/>
|
||||
<field name="first_responded_at" readonly="1"/>
|
||||
<field name="resolved_at" readonly="1"/>
|
||||
<field name="last_detail_sync" readonly="1"/>
|
||||
</group>
|
||||
<group string="Metrik SLA (Hari)">
|
||||
<field name="first_response_time_days" readonly="1"/>
|
||||
<field name="total_agent_time_days" readonly="1"/>
|
||||
<field name="total_customer_time_days" readonly="1"/>
|
||||
<field name="shared_wait_time_days" readonly="1"/>
|
||||
<field name="third_party_time_days" readonly="1"/>
|
||||
<field name="resolution_time_days" readonly="1"/>
|
||||
<field name="shared_wait_time_hours" invisible="1"/>
|
||||
<field name="third_party_time_hours" invisible="1"/>
|
||||
<!-- Field jam disembunyikan (hide) -->
|
||||
<field name="first_response_time_hours" invisible="1"/>
|
||||
<field name="total_agent_time_hours" invisible="1"/>
|
||||
<field name="total_customer_time_hours" invisible="1"/>
|
||||
<field name="resolution_time_hours" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- LIST VIEW -->
|
||||
<record id="view_freshservice_ticket_tree" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.tree</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Freshservice Tickets"
|
||||
decoration-danger="current_responsibility == 'internal'"
|
||||
decoration-warning="current_responsibility == 'customer'"
|
||||
decoration-muted="status in ('resolved', 'closed')">
|
||||
<header>
|
||||
<button name="action_sync_recent_tickets"
|
||||
string="Tarik Tiket Terbaru Freshservice"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-cloud-download"/>
|
||||
</header>
|
||||
<field name="external_ticket_id"/>
|
||||
<field name="name"/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-info="status == 'open'"
|
||||
decoration-warning="status == 'pending'"
|
||||
decoration-success="status == 'resolved'"
|
||||
decoration-muted="status == 'closed'"/>
|
||||
<field name="current_responsibility" widget="badge"
|
||||
decoration-danger="current_responsibility == 'internal'"
|
||||
decoration-warning="current_responsibility == 'customer'"
|
||||
decoration-success="current_responsibility == 'closed'"/>
|
||||
<field name="total_agent_time_display"/>
|
||||
<field name="total_customer_time_display"/>
|
||||
<field name="shared_wait_time_display" optional="show"/>
|
||||
<field name="third_party_time_display" optional="show"/>
|
||||
<field name="first_response_time_display"/>
|
||||
<field name="responder_name"/>
|
||||
<field name="requester_name"/>
|
||||
<field name="ticket_created_at"/>
|
||||
<field name="is_detail_synced" widget="boolean_toggle" optional="hide" string="Detail Di-fetch"/>
|
||||
<field name="is_mock_data" widget="boolean_toggle" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- KANBAN VIEW -->
|
||||
<record id="view_freshservice_ticket_kanban" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.kanban</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="status" class="o_kanban_small_column">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="external_ticket_id"/>
|
||||
<field name="priority"/>
|
||||
<field name="current_responsibility"/>
|
||||
<field name="total_agent_time_display"/>
|
||||
<field name="total_customer_time_display"/>
|
||||
<field name="responder_name"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click p-3 rounded">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge text-bg-light fw-bold">#<field name="external_ticket_id"/></span>
|
||||
<field name="priority" widget="priority"/>
|
||||
</div>
|
||||
<div class="fw-bold mt-2">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center text-muted small mt-1">
|
||||
<span>Agen: <field name="responder_name"/></span>
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
|
||||
<span class="badge text-bg-danger">Agen: <field name="total_agent_time_display"/></span>
|
||||
<span class="badge text-bg-warning">User: <field name="total_customer_time_display"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- PIVOT VIEW -->
|
||||
<record id="view_freshservice_ticket_pivot" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.pivot</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Analisis Durasi Tiket SLA (Hari)">
|
||||
<field name="responder_name" type="row"/>
|
||||
<field name="current_responsibility" type="col"/>
|
||||
<field name="total_agent_time_days" type="measure"/>
|
||||
<field name="total_customer_time_days" type="measure"/>
|
||||
<field name="first_response_time_days" type="measure"/>
|
||||
<field name="resolution_time_days" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- GRAPH VIEW -->
|
||||
<record id="view_freshservice_ticket_graph" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.graph</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Perbandingan Durasi Agen vs User (Hari)" type="bar">
|
||||
<field name="responder_name"/>
|
||||
<field name="total_agent_time_days" type="measure"/>
|
||||
<field name="total_customer_time_days" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- SEARCH VIEW -->
|
||||
<record id="view_freshservice_ticket_search" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.search</field>
|
||||
<field name="model">freshservice.ticket</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Cari Tiket">
|
||||
<field name="name" filter_domain="['|', ('name', 'ilike', self), ('external_ticket_id', 'ilike', self)]"/>
|
||||
<field name="external_ticket_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="requester_name"/>
|
||||
<field name="responder_name"/>
|
||||
<filter name="filter_my_tickets" string="Tiket Saya" domain="[('user_id', '=', uid)]"/>
|
||||
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
|
||||
<filter name="filter_waiting_user" string="Waiting on Requester (User)" domain="[('current_responsibility', '=', 'customer')]"/>
|
||||
<separator/>
|
||||
<filter name="filter_open_progress" string="Active Tickets (Not Completed)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
||||
<filter name="filter_resolved" string="Completed (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
||||
<separator/>
|
||||
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
|
||||
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
|
||||
<group>
|
||||
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
||||
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/>
|
||||
<filter name="group_by_responder" string="Agent (IT)" context="{'group_by': 'responder_name'}"/>
|
||||
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<record id="action_freshservice_ticket" model="ir.actions.act_window">
|
||||
<field name="name">Daftar Tiket</field>
|
||||
<field name="res_model">freshservice.ticket</field>
|
||||
<field name="view_mode">list,form,kanban,pivot,graph</field>
|
||||
<field name="search_view_id" ref="view_freshservice_ticket_search"/>
|
||||
<field name="context">{'search_default_filter_open_progress': 0}</field>
|
||||
<field name="limit">200</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Belum ada tiket Freshservice yang dimuat!
|
||||
</p>
|
||||
<p>
|
||||
Klik tombol <strong>"Tarik Tiket Terbaru Freshservice"</strong> pada header tabel untuk menarik daftar tiket dari Freshservice secara otomatis.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
65
gmn_freshservice/views/gmn_menu_views_v01.xml
Normal file
65
gmn_freshservice/views/gmn_menu_views_v01.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- TOP LEVEL ROOT MENU -->
|
||||
<menuitem id="menu_freshservice_root"
|
||||
name="Ticket Audit"
|
||||
web_icon="fa fa-clock-o,#34495e,#ffffff"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- SERVER ACTION: Buka Menu Sekaligus Auto-Fetch Data Terbaru Freshservice -->
|
||||
<record id="action_server_open_freshservice_tickets" model="ir.actions.server">
|
||||
<field name="name">Buka & Auto-Fetch Tiket Freshservice</field>
|
||||
<field name="model_id" ref="model_freshservice_ticket"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = model.action_open_and_fetch_tickets()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- MAIN MENUS -->
|
||||
<menuitem id="menu_freshservice_tickets"
|
||||
name="Daftar Tiket"
|
||||
parent="menu_freshservice_root"
|
||||
action="action_server_open_freshservice_tickets"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_freshservice_stage_logs"
|
||||
name="Audit Trail Riwayat Status"
|
||||
parent="menu_freshservice_root"
|
||||
action="action_freshservice_ticket_stage_log"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_freshservice_bottleneck_wizard"
|
||||
name="Laporan Bottleneck (Excel)"
|
||||
parent="menu_freshservice_root"
|
||||
action="action_freshservice_ticket_bottleneck_wizard"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- CONFIGURATION MENU (Manager Only) -->
|
||||
<menuitem id="menu_freshservice_config_parent"
|
||||
name="Konfigurasi"
|
||||
parent="menu_freshservice_root"
|
||||
groups="gmn_freshservice.group_freshservice_manager"
|
||||
sequence="90"/>
|
||||
|
||||
<menuitem id="menu_freshservice_config_settings"
|
||||
name="Pengaturan Integrasi"
|
||||
parent="menu_freshservice_config_parent"
|
||||
action="action_freshservice_config_settings"
|
||||
groups="gmn_freshservice.group_freshservice_manager"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_freshservice_holiday"
|
||||
name="Hari Libur & 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>
|
||||
46
gmn_freshservice/views/gmn_res_config_settings_views_v01.xml
Normal file
46
gmn_freshservice/views/gmn_res_config_settings_views_v01.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_freshservice" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.freshservice</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority" eval="90"/>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Freshservice Audit" string="Freshservice Audit" name="gmn_freshservice">
|
||||
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
|
||||
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
||||
<field name="freshservice_url"/>
|
||||
</setting>
|
||||
|
||||
<setting id="freshservice_api_key_setting" help="API Key dari profil Freshservice (wajib diisi untuk Live API)">
|
||||
<field name="freshservice_api_key" password="True"/>
|
||||
</setting>
|
||||
|
||||
<setting id="freshservice_sync_limit_setting" help="Batas jumlah tiket terbaru yang ditarik saat membuka daftar tiket (default: 200)">
|
||||
<field name="freshservice_sync_limit"/>
|
||||
</setting>
|
||||
|
||||
<setting id="freshservice_connection_setting" help="Uji koneksi ke Live Freshservice API">
|
||||
<div class="mt8">
|
||||
<button name="action_test_freshservice_connection"
|
||||
type="object"
|
||||
string="Tes Koneksi Live API"
|
||||
class="btn-primary"
|
||||
icon="fa-plug"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_freshservice_config_settings" model="ir.actions.act_window">
|
||||
<field name="name">Pengaturan Freshservice</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">main</field>
|
||||
<field name="context">{'module': 'gmn_freshservice'}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
4
gmn_freshservice/wizard/__init__.py
Normal file
4
gmn_freshservice/wizard/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package wizard untuk laporan bottleneck tiket dan utilitas impor Freshservice."""
|
||||
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
||||
from . import gmn_freshservice_holiday_import_wizard_v01
|
||||
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- FORM VIEW WIZARD BOTTLENECK -->
|
||||
<record id="view_freshservice_ticket_bottleneck_wizard_form" model="ir.ui.view">
|
||||
<field name="name">freshservice.ticket.bottleneck.wizard.form</field>
|
||||
<field name="model">freshservice.ticket.bottleneck.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Laporan Analisis Bottleneck Tiket (Excel)">
|
||||
<field name="state" invisible="1"/>
|
||||
|
||||
<!-- STATE 1: PILIH PARAMETER -->
|
||||
<div invisible="state != 'choose'">
|
||||
<div class="alert alert-primary mb-3 d-flex align-items-center gap-2" role="status" invisible="True">
|
||||
<i class="fa fa-info-circle fa-2x" title="Informasi"/>
|
||||
<div>
|
||||
<strong>Ekspor Laporan Analisis Bottleneck ke Excel (.xlsx)</strong><br/>
|
||||
Laporan ini dirancang khusus sebagai <em>bahan meeting</em> untuk mengidentifikasi tahapan, penanggung jawab, dan faktor yang menyebabkan tiket tertahan lama (Waiting on Us vs Waiting on User).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<group invisible="True">
|
||||
<group string="Pilihan Laporan">
|
||||
<field name="report_type" widget="radio"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- MODE SINGLE TICKET -->
|
||||
<group>
|
||||
<group string="Identitas Tiket">
|
||||
<field name="external_ticket_id" placeholder="Contoh: 992"/>
|
||||
<field name="ticket_id" options="{'no_create': True}" placeholder="Atau cari judul tiket di Odoo..."/>
|
||||
</group>
|
||||
<group string="Simulasi Cut-Off & Opsi">
|
||||
<field name="as_of" placeholder="Kosongkan jika real-time s/d saat ini"/>
|
||||
<field invisible="True" name="auto_sync_detail"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- MODE RANKING MULTI-TIKET -->
|
||||
<group string="Filter Multi-Tiket (Ranking)" invisible="report_type != 'ranking'">
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="status_filter"/>
|
||||
<field name="as_of" string="Batas Cut-Off (As-Of)" placeholder="Kosongkan jika real-time s/d saat ini"/>
|
||||
<field name="auto_sync_detail"/>
|
||||
</group>
|
||||
<group colspan="2">
|
||||
<field name="ticket_ids" widget="many2many_tags" placeholder="Kosongkan untuk menyertakan seluruh tiket sesuai filter di atas"/>
|
||||
</group>
|
||||
</group>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- FOOTER BUTTONS -->
|
||||
<footer>
|
||||
<div invisible="state != 'choose'" class="d-flex flex-row gap-2 justify-content-end">
|
||||
<button name="action_generate_excel"
|
||||
string="Export"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-file-excel-o"/>
|
||||
<button string="Batal"
|
||||
class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</div>
|
||||
|
||||
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ACTION WINDOW WIZARD (POP-UP MODAL) -->
|
||||
<record id="action_freshservice_ticket_bottleneck_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Laporan Analisis Bottleneck Excel</field>
|
||||
<field name="res_model">freshservice.ticket.bottleneck.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_freshservice_ticket"/>
|
||||
<field name="binding_view_types">form,list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user