[UPD] add dokumentasi fucntion
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Modul utama GMN Freshservice.
|
||||||
|
Import semua sub-package: models, services, dan wizard.
|
||||||
|
"""
|
||||||
from . import models
|
from . import models
|
||||||
from . import services
|
from . import services
|
||||||
from . import wizard
|
from . import wizard
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Package models: semua model Odoo untuk modul Freshservice.
|
||||||
|
Urutan import penting karena ticket bergantung pada stage_log dan conversation.
|
||||||
|
"""
|
||||||
from . import gmn_res_config_settings_v01
|
from . import gmn_res_config_settings_v01
|
||||||
from . import gmn_freshservice_ticket_stage_log_v01
|
from . import gmn_freshservice_ticket_stage_log_v01
|
||||||
from . import gmn_freshservice_ticket_conversation_v01
|
from . import gmn_freshservice_ticket_conversation_v01
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice.
|
||||||
|
Setiap baris adalah satu pesan, baik dari user maupun dari agen IT.
|
||||||
|
"""
|
||||||
from odoo import models, fields
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
class FreshserviceTicketConversation(models.Model):
|
class FreshserviceTicketConversation(models.Model):
|
||||||
_name = 'freshservice.ticket.conversation'
|
"""Menyimpan pesan/balasan pada tiket.
|
||||||
|
Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel.
|
||||||
|
"""
|
||||||
_description = 'Percakapan & Balasan Tiket Freshservice'
|
_description = 'Percakapan & Balasan Tiket Freshservice'
|
||||||
_order = 'created_at asc, id asc'
|
_order = 'created_at asc, id asc'
|
||||||
|
|
||||||
@@ -32,5 +38,9 @@ class FreshserviceTicketConversation(models.Model):
|
|||||||
created_at = fields.Datetime(string='Waktu Kirim')
|
created_at = fields.Datetime(string='Waktu Kirim')
|
||||||
|
|
||||||
def _compute_sender_type(self):
|
def _compute_sender_type(self):
|
||||||
|
"""Tentukan siapa yang kirim pesan:
|
||||||
|
- 'customer' jika incoming=True (pesan dari user)
|
||||||
|
- 'agent' jika incoming=False (balasan dari agen IT)
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.sender_type = 'customer' if rec.incoming else 'agent'
|
rec.sender_type = 'customer' if rec.incoming else 'agent'
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi.
|
||||||
|
Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan.
|
||||||
|
"""
|
||||||
from datetime import timedelta, datetime, time
|
from datetime import timedelta, datetime, time
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api
|
||||||
|
|
||||||
@@ -240,6 +243,11 @@ def format_duration_human(hours_float):
|
|||||||
return f"{days} Hari"
|
return f"{days} Hari"
|
||||||
|
|
||||||
class FreshserviceTicketStageLog(models.Model):
|
class FreshserviceTicketStageLog(models.Model):
|
||||||
|
"""Satu baris = satu fase/status di tiket.
|
||||||
|
Contoh: Open → In Progress → Pending User → Resolved.
|
||||||
|
Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya,
|
||||||
|
dan siapa yang bertanggung jawab (Agent/User/Third Party).
|
||||||
|
"""
|
||||||
_name = 'freshservice.ticket.stage.log'
|
_name = 'freshservice.ticket.stage.log'
|
||||||
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
|
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
|
||||||
_order = 'start_date asc, id asc'
|
_order = 'start_date asc, id asc'
|
||||||
@@ -345,11 +353,15 @@ class FreshserviceTicketStageLog(models.Model):
|
|||||||
|
|
||||||
@api.depends('end_date')
|
@api.depends('end_date')
|
||||||
def _compute_is_active(self):
|
def _compute_is_active(self):
|
||||||
|
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.is_active = not bool(rec.end_date)
|
rec.is_active = not bool(rec.end_date)
|
||||||
|
|
||||||
@api.depends('start_date', 'end_date', 'is_active')
|
@api.depends('start_date', 'end_date', 'is_active')
|
||||||
def _compute_working_metrics(self):
|
def _compute_working_metrics(self):
|
||||||
|
"""Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat)
|
||||||
|
dan konversi ke hari kerja (1 hari = 8 jam).
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.start_date:
|
if rec.start_date:
|
||||||
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
|
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
|
||||||
@@ -363,6 +375,9 @@ class FreshserviceTicketStageLog(models.Model):
|
|||||||
|
|
||||||
@api.depends('start_date', 'end_date')
|
@api.depends('start_date', 'end_date')
|
||||||
def _compute_duration(self):
|
def _compute_duration(self):
|
||||||
|
"""Hitung durasi dalam hari kalender dan jam total.
|
||||||
|
Kalau stage masih aktif, hitung sampai waktu sekarang.
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.start_date:
|
if rec.start_date:
|
||||||
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)
|
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Model utama Freshservice Ticket.
|
||||||
|
Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API.
|
||||||
|
"""
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -10,6 +13,11 @@ from .gmn_freshservice_ticket_stage_log_v01 import format_duration_human, format
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class FreshserviceTicket(models.Model):
|
class FreshserviceTicket(models.Model):
|
||||||
|
"""Model tiket Freshservice di Odoo.
|
||||||
|
Menyimpan info tiket (judul, status, prioritas, pemohon, agen),
|
||||||
|
metrik SLA (first response, resolution time, breakdown per tanggung jawab),
|
||||||
|
dan relasi ke stage log (audit trail) serta percakapan.
|
||||||
|
"""
|
||||||
_name = 'freshservice.ticket'
|
_name = 'freshservice.ticket'
|
||||||
_description = 'Freshservice Ticket with Audit Trail'
|
_description = 'Freshservice Ticket with Audit Trail'
|
||||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
@@ -212,6 +220,12 @@ class FreshserviceTicket(models.Model):
|
|||||||
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active'
|
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active'
|
||||||
)
|
)
|
||||||
def _compute_time_metrics(self):
|
def _compute_time_metrics(self):
|
||||||
|
"""Hitung semua metrik waktu tiket:
|
||||||
|
- First Response Time (berapa lama sampai agen pertama kali merespon)
|
||||||
|
- Breakdown jam kerja per tanggung jawab (Agent/User/Meeting/Third Party)
|
||||||
|
- Resolution Time (total waktu sampai tiket selesai)
|
||||||
|
- Status tanggung jawab saat ini (siapa yang harus action)
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
# 1. Hitung First Response
|
# 1. Hitung First Response
|
||||||
if rec.ticket_created_at and rec.first_responded_at:
|
if rec.ticket_created_at and rec.first_responded_at:
|
||||||
@@ -302,7 +316,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def action_assign_to_me(self):
|
def action_assign_to_me(self):
|
||||||
"""Menugaskan tiket ini ke user Odoo yang sedang login"""
|
"""Tombol 'Assign ke Saya': tugaskan tiket ini ke user yang sedang login.
|
||||||
|
Otomatis catat di chatter siapa yang ambil alih tiket.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
current_user = self.env.user
|
current_user = self.env.user
|
||||||
vals = {'user_id': current_user.id}
|
vals = {'user_id': current_user.id}
|
||||||
@@ -348,10 +364,12 @@ class FreshserviceTicket(models.Model):
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
def action_sync_from_freshservice(self):
|
def action_sync_from_freshservice(self):
|
||||||
"""
|
"""Tarik data lengkap tiket dari Freshservice API.
|
||||||
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
|
Proses:
|
||||||
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan.
|
1. Ambil detail tiket (subject, status, requester, dll)
|
||||||
Mendukung concurrency row-lock untuk keamanan multi-user.
|
2. Ambil audit trail activities lalu rekonstruksi jadi stage log
|
||||||
|
3. Ambil percakapan (reply/note)
|
||||||
|
Pakai row-lock supaya aman kalau 2 user sync bersamaan.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
@@ -959,7 +977,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _extract_ticket_ids_from_domain(self, domain):
|
def _extract_ticket_ids_from_domain(self, domain):
|
||||||
"""Ambil angka ticket ID dari domain pencarian agar bisa fetch on-demand."""
|
"""Cari angka-angka ticket ID dari domain pencarian Odoo.
|
||||||
|
Contoh: domain [('external_ticket_id', '=', '992')] → return ['992']
|
||||||
|
"""
|
||||||
ticket_ids = []
|
ticket_ids = []
|
||||||
if not domain:
|
if not domain:
|
||||||
return ticket_ids
|
return ticket_ids
|
||||||
@@ -982,7 +1002,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _fetch_and_create_ticket_by_external_id(self, ticket_id):
|
def _fetch_and_create_ticket_by_external_id(self, ticket_id):
|
||||||
"""Ambil tiket tertentu dari Freshservice jika belum ada di Odoo."""
|
"""Cari tiket di Odoo berdasarkan ID Freshservice.
|
||||||
|
Kalau belum ada, buat record baru lalu langsung sync dari API.
|
||||||
|
"""
|
||||||
if ticket_id in (None, ''):
|
if ticket_id in (None, ''):
|
||||||
return self.browse()
|
return self.browse()
|
||||||
|
|
||||||
@@ -1016,10 +1038,8 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def action_open_and_fetch_tickets(self, tiket_id = None):
|
def action_open_and_fetch_tickets(self, tiket_id = None):
|
||||||
"""
|
"""Dipanggil saat user klik menu 'Daftar Tiket'.
|
||||||
Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket',
|
Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list.
|
||||||
lalu langsung membuka tampilan list tiket secara real-time!
|
|
||||||
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
|
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
|
||||||
@@ -1038,6 +1058,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def name_search(self, name='', args=None, operator='ilike', limit=100):
|
def name_search(self, name='', args=None, operator='ilike', limit=100):
|
||||||
|
"""Override name_search: kalau user ketik nomor tiket yang belum ada di Odoo,
|
||||||
|
otomatis fetch dari Freshservice dulu baru tampilkan.
|
||||||
|
"""
|
||||||
if name and isinstance(name, str):
|
if name and isinstance(name, str):
|
||||||
ticket_ids = re.findall(r'\d+', name.strip())
|
ticket_ids = re.findall(r'\d+', name.strip())
|
||||||
for ticket_id in ticket_ids:
|
for ticket_id in ticket_ids:
|
||||||
@@ -1047,9 +1070,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
|
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
|
||||||
"""
|
"""Override web_search_read: otomatis sync tiket terbaru dari Freshservice
|
||||||
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket.
|
setiap kali user buka atau refresh halaman list tiket.
|
||||||
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
|
Juga cek kalau ada nomor tiket di pencarian yang belum ada di Odoo.
|
||||||
"""
|
"""
|
||||||
if offset == 0 and not self.env.context.get('skip_auto_fetch'):
|
if offset == 0 and not self.env.context.get('skip_auto_fetch'):
|
||||||
try:
|
try:
|
||||||
@@ -1067,9 +1090,8 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.readonly
|
@api.readonly
|
||||||
def web_read(self, specification: dict[str, dict]) -> list[dict]:
|
def web_read(self, specification: dict[str, dict]) -> list[dict]:
|
||||||
"""
|
"""Override web_read: saat user buka detail tiket (form view),
|
||||||
Ketika pengguna membuka form view (detail tiket), jika detail audit trail
|
kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice.
|
||||||
belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand.
|
|
||||||
"""
|
"""
|
||||||
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'):
|
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'):
|
||||||
# Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
|
# Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
|
||||||
@@ -1090,8 +1112,8 @@ class FreshserviceTicket(models.Model):
|
|||||||
return super().web_read(specification)
|
return super().web_read(specification)
|
||||||
|
|
||||||
def action_sync_recent_tickets(self):
|
def action_sync_recent_tickets(self):
|
||||||
"""
|
"""Tombol manual 'Tarik Tiket Terbaru' di halaman list.
|
||||||
Tombol manual 'Tarik Tiket Terbaru Freshservice' di header tabel jika user ingin refresh paksa.
|
Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice.
|
||||||
"""
|
"""
|
||||||
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
|
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Konfigurasi Settings untuk koneksi Freshservice.
|
||||||
|
User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo.
|
||||||
|
"""
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class ResConfigSettings(models.TransientModel):
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
|
||||||
|
Bisa diakses dari menu Settings > Freshservice.
|
||||||
|
"""
|
||||||
_inherit = 'res.config.settings'
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
freshservice_url = fields.Char(
|
freshservice_url = fields.Char(
|
||||||
@@ -30,7 +37,9 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def action_test_freshservice_connection(self):
|
def action_test_freshservice_connection(self):
|
||||||
"""Uji konektivitas ke server Freshservice"""
|
"""Tombol 'Test Connection' di halaman Settings.
|
||||||
|
Coba panggil API Freshservice untuk cek apakah URL dan API Key valid.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
url = (self.freshservice_url or "").rstrip("/")
|
url = (self.freshservice_url or "").rstrip("/")
|
||||||
if not url:
|
if not url:
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Package services: berisi client untuk koneksi ke Freshservice API."""
|
||||||
from . import gmn_freshservice_client_v01
|
from . import gmn_freshservice_client_v01
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client untuk koneksi ke Freshservice REST API v2.
|
||||||
|
Bisa jalan dalam 2 mode: Live (panggil API beneran) atau Mock (data dummy untuk testing).
|
||||||
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
@@ -32,6 +35,11 @@ class FreshserviceClient:
|
|||||||
_dept_cache = {}
|
_dept_cache = {}
|
||||||
|
|
||||||
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
|
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
|
||||||
|
"""Inisialisasi koneksi ke Freshservice.
|
||||||
|
- base_url: URL instance Freshservice (misal: https://company.freshservice.com)
|
||||||
|
- api_key: token API untuk otentikasi
|
||||||
|
- use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet
|
||||||
|
"""
|
||||||
self.base_url = (base_url or "").rstrip("/")
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
self.api_key = api_key or ""
|
self.api_key = api_key or ""
|
||||||
self.use_mock = False
|
self.use_mock = False
|
||||||
@@ -295,6 +303,13 @@ class FreshserviceClient:
|
|||||||
# "conversations": conversations_json,
|
# "conversations": conversations_json,
|
||||||
# }
|
# }
|
||||||
def _get_live_payload(self, ticket_id):
|
def _get_live_payload(self, ticket_id):
|
||||||
|
"""Ambil data lengkap tiket dari server live Freshservice via REST API.
|
||||||
|
Memanggil beberapa endpoint:
|
||||||
|
1. /api/v2/tickets/{id}?include=stats,requester - info tiket & metrik
|
||||||
|
2. /api/v2/tickets/{id}/activities - audit trail/riwayat perubahan
|
||||||
|
3. /api/v2/tickets/{id}/conversations - riwayat pesan/chat
|
||||||
|
Mengembalikan dictionary berisi ketiga data di atas.
|
||||||
|
"""
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
raise UserError(
|
raise UserError(
|
||||||
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!"
|
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!"
|
||||||
|
|||||||
3
gmn_freshservice/tests/__init__.py
Normal file
3
gmn_freshservice/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Package test untuk modul GMN Freshservice."""
|
||||||
|
from . import test_responsibility_rules
|
||||||
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal file
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Test untuk memastikan pembagian tanggung jawab (responsibility bucket)
|
||||||
|
dan laporan tiket berjalan dengan benar.
|
||||||
|
|
||||||
|
Data expected value diambil dari tiket #9658 yang sudah disetujui.
|
||||||
|
Test ini hanya jalan kalau fixture tiket #9658 sudah di-install.
|
||||||
|
"""
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponsibilityRules(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Siapkan model Ticket supaya bisa dipakai di semua test."""
|
||||||
|
super().setUpClass()
|
||||||
|
cls.Ticket = cls.env['freshservice.ticket']
|
||||||
|
|
||||||
|
def _ticket_9658(self):
|
||||||
|
"""Cari tiket #9658 di database. Return kosong kalau belum ada."""
|
||||||
|
return self.Ticket.search([('external_ticket_id', '=', '9658')], limit=1)
|
||||||
|
|
||||||
|
def test_acceptance_1_eight_buckets_sum_to_total(self):
|
||||||
|
"""Cek 8 bucket tanggung jawab. Jumlah jam masing-masing bucket
|
||||||
|
harus cocok dengan nilai expected, dan total semua bucket
|
||||||
|
harus sama dengan grand total.
|
||||||
|
"""
|
||||||
|
ticket = self._ticket_9658()
|
||||||
|
if not ticket:
|
||||||
|
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||||
|
values = {
|
||||||
|
'internal': 30.70,
|
||||||
|
'it_hold': 10.74,
|
||||||
|
'customer': 31.34,
|
||||||
|
'user_after_auto_resolve': 29.89,
|
||||||
|
'needs_verification': 36.72,
|
||||||
|
'scheduled_meeting': 13.26,
|
||||||
|
'third_party': 27.22,
|
||||||
|
'resolved_idle': 0.0,
|
||||||
|
}
|
||||||
|
actual = {key: 0.0 for key in values}
|
||||||
|
for log in ticket.stage_log_ids:
|
||||||
|
key = log.override_responsibility or log.responsibility
|
||||||
|
if key in actual:
|
||||||
|
actual[key] += log.duration_hours or 0.0
|
||||||
|
for key, expected in values.items():
|
||||||
|
self.assertAlmostEqual(actual[key], expected, places=2, msg=key)
|
||||||
|
self.assertAlmostEqual(sum(actual.values()), sum(values.values()), places=2)
|
||||||
|
|
||||||
|
def test_acceptance_2_as_of_and_overrides(self):
|
||||||
|
"""Pastikan tiket punya field first_ack_at dan
|
||||||
|
first_meaningful_response_at (waktu respon pertama).
|
||||||
|
"""
|
||||||
|
ticket = self._ticket_9658()
|
||||||
|
if not ticket:
|
||||||
|
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||||
|
self.assertTrue(all(hasattr(ticket, field) for field in (
|
||||||
|
'first_ack_at', 'first_meaningful_response_at',
|
||||||
|
)))
|
||||||
|
|
||||||
|
def test_acceptance_3_bucket_invariant(self):
|
||||||
|
"""Cek invariant: total jam dari semua bucket harus sama
|
||||||
|
dengan total jam dari semua stage log. Tidak boleh ada
|
||||||
|
jam yang hilang atau dobel.
|
||||||
|
"""
|
||||||
|
ticket = self._ticket_9658()
|
||||||
|
if not ticket:
|
||||||
|
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||||
|
bucket_names = {
|
||||||
|
'internal', 'it_hold', 'customer', 'user_after_auto_resolve',
|
||||||
|
'needs_verification', 'scheduled_meeting', 'third_party',
|
||||||
|
'resolved_idle',
|
||||||
|
}
|
||||||
|
bucket_total = sum(
|
||||||
|
log.duration_hours or 0.0
|
||||||
|
for log in ticket.stage_log_ids
|
||||||
|
if (log.override_responsibility or log.responsibility) in bucket_names
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(bucket_total, sum(log.duration_hours or 0.0 for log in ticket.stage_log_ids), places=2)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Package wizard untuk laporan bottleneck tiket Freshservice."""
|
||||||
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Wizard untuk generate laporan Excel analisis bottleneck tiket Freshservice.
|
||||||
|
Bisa bikin 2 jenis laporan:
|
||||||
|
1. Detail bottleneck satu tiket (breakdown per stage siapa yang lama)
|
||||||
|
2. Ranking multi-tiket (perbandingan banyak tiket sekaligus)
|
||||||
|
"""
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
import base64
|
import base64
|
||||||
@@ -11,7 +16,12 @@ from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
||||||
|
"""Wizard popup untuk bikin laporan Excel bottleneck.
|
||||||
|
User pilih tiket, filter tanggal/status, lalu klik Generate.
|
||||||
|
Hasilnya file Excel yang bisa langsung diunduh.
|
||||||
|
"""
|
||||||
_name = 'freshservice.ticket.bottleneck.wizard'
|
_name = 'freshservice.ticket.bottleneck.wizard'
|
||||||
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
|
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
|
||||||
|
|
||||||
@@ -74,6 +84,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
|
|
||||||
@api.onchange('ticket_id')
|
@api.onchange('ticket_id')
|
||||||
def _onchange_ticket_id(self):
|
def _onchange_ticket_id(self):
|
||||||
|
"""Kalau user pilih tiket dari dropdown, otomatis isi nomor ID Freshservice-nya."""
|
||||||
if self.ticket_id and self.ticket_id.external_ticket_id:
|
if self.ticket_id and self.ticket_id.external_ticket_id:
|
||||||
self.external_ticket_id = self.ticket_id.external_ticket_id
|
self.external_ticket_id = self.ticket_id.external_ticket_id
|
||||||
|
|
||||||
@@ -86,11 +97,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
return clean
|
return clean
|
||||||
|
|
||||||
def _effective_responsibility(self, log):
|
def _effective_responsibility(self, log):
|
||||||
"""Return the reviewed classification when present, otherwise the automatic one."""
|
"""Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review,
|
||||||
|
kalau belum pakai hasil deteksi otomatis.
|
||||||
|
"""
|
||||||
return log.override_responsibility or log.responsibility
|
return log.override_responsibility or log.responsibility
|
||||||
|
|
||||||
def _clipped_stage_interval(self, log, report_as_of):
|
def _clipped_stage_interval(self, log, report_as_of):
|
||||||
"""Return the reportable (start, end) interval, clipped at the report cut-off."""
|
"""Potong interval stage log sesuai cut-off laporan.
|
||||||
|
Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu.
|
||||||
|
"""
|
||||||
start = log.start_date
|
start = log.start_date
|
||||||
if not start:
|
if not start:
|
||||||
return False, False
|
return False, False
|
||||||
@@ -132,6 +147,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
target_ticket.action_sync_from_freshservice()
|
target_ticket.action_sync_from_freshservice()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
|
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
|
||||||
|
else :
|
||||||
|
try:
|
||||||
|
target_ticket.action_sync_from_freshservice()
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
|
||||||
|
|
||||||
if not target_ticket:
|
if not target_ticket:
|
||||||
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
|
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
|
||||||
|
|||||||
Reference in New Issue
Block a user