[UPD] add dokumentasi fucntion

This commit is contained in:
Madanil
2026-10-20 08:57:07 +07:00
parent 62e545276f
commit c0a185478b
14 changed files with 203 additions and 23 deletions

View File

@@ -1,4 +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

View File

@@ -1,4 +1,7 @@
# -*- 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_ticket_stage_log_v01
from . import gmn_freshservice_ticket_conversation_v01

View File

@@ -1,8 +1,14 @@
# -*- 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):
_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'
_order = 'created_at asc, id asc'
@@ -32,5 +38,9 @@ class FreshserviceTicketConversation(models.Model):
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'

View File

@@ -1,4 +1,7 @@
# -*- 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
@@ -240,6 +243,11 @@ def format_duration_human(hours_float):
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'
@@ -345,11 +353,15 @@ class FreshserviceTicketStageLog(models.Model):
@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)
@@ -363,6 +375,9 @@ class FreshserviceTicketStageLog(models.Model):
@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)

View File

@@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
"""Model utama Freshservice Ticket.
Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API.
"""
import re
import logging
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__)
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'
_description = 'Freshservice Ticket with Audit Trail'
_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'
)
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:
# 1. Hitung First Response
if rec.ticket_created_at and rec.first_responded_at:
@@ -302,7 +316,9 @@ class FreshserviceTicket(models.Model):
}
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()
current_user = self.env.user
vals = {'user_id': current_user.id}
@@ -348,10 +364,12 @@ class FreshserviceTicket(models.Model):
return False, ""
def action_sync_from_freshservice(self):
"""
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan.
Mendukung concurrency row-lock untuk keamanan multi-user.
"""Tarik data lengkap tiket dari Freshservice API.
Proses:
1. Ambil detail tiket (subject, status, requester, dll)
2. Ambil audit trail activities lalu rekonstruksi jadi stage log
3. Ambil percakapan (reply/note)
Pakai row-lock supaya aman kalau 2 user sync bersamaan.
"""
self.ensure_one()
@@ -959,7 +977,9 @@ class FreshserviceTicket(models.Model):
@api.model
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 = []
if not domain:
return ticket_ids
@@ -982,7 +1002,9 @@ class FreshserviceTicket(models.Model):
@api.model
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, ''):
return self.browse()
@@ -1016,10 +1038,8 @@ class FreshserviceTicket(models.Model):
@api.model
def action_open_and_fetch_tickets(self, tiket_id = None):
"""
Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket',
lalu langsung membuka tampilan list tiket secara real-time!
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
"""Dipanggil saat user klik menu 'Daftar Tiket'.
Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list.
"""
try:
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
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):
ticket_ids = re.findall(r'\d+', name.strip())
for ticket_id in ticket_ids:
@@ -1047,9 +1070,9 @@ class FreshserviceTicket(models.Model):
@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
"""
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket.
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
"""Override web_search_read: otomatis sync tiket terbaru dari Freshservice
setiap kali user buka atau refresh halaman list tiket.
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'):
try:
@@ -1067,9 +1090,8 @@ class FreshserviceTicket(models.Model):
@api.readonly
def web_read(self, specification: dict[str, dict]) -> list[dict]:
"""
Ketika pengguna membuka form view (detail tiket), jika detail audit trail
belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand.
"""Override web_read: saat user buka detail tiket (form view),
kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice.
"""
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
@@ -1090,8 +1112,8 @@ class FreshserviceTicket(models.Model):
return super().web_read(specification)
def action_sync_recent_tickets(self):
"""
Tombol manual 'Tarik Tiket Terbaru Freshservice' di header tabel jika user ingin refresh paksa.
"""Tombol manual 'Tarik Tiket Terbaru' di halaman list.
Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice.
"""
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
return {

View File

@@ -1,9 +1,16 @@
# -*- 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(
@@ -30,7 +37,9 @@ class ResConfigSettings(models.TransientModel):
)
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()
url = (self.freshservice_url or "").rstrip("/")
if not url:

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
"""Package services: berisi client untuk koneksi ke Freshservice API."""
from . import gmn_freshservice_client_v01

View File

@@ -1,4 +1,7 @@
# -*- 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
@@ -32,6 +35,11 @@ class FreshserviceClient:
_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
@@ -295,6 +303,13 @@ class FreshserviceClient:
# "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!"

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""Package test untuk modul GMN Freshservice."""
from . import test_responsibility_rules

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""Test untuk memastikan pembagian tanggung jawab (responsibility bucket)
dan laporan tiket berjalan dengan benar.
Data expected value diambil dari tiket #9658 yang sudah disetujui.
Test ini hanya jalan kalau fixture tiket #9658 sudah di-install.
"""
from odoo.tests.common import TransactionCase
class TestResponsibilityRules(TransactionCase):
@classmethod
def setUpClass(cls):
"""Siapkan model Ticket supaya bisa dipakai di semua test."""
super().setUpClass()
cls.Ticket = cls.env['freshservice.ticket']
def _ticket_9658(self):
"""Cari tiket #9658 di database. Return kosong kalau belum ada."""
return self.Ticket.search([('external_ticket_id', '=', '9658')], limit=1)
def test_acceptance_1_eight_buckets_sum_to_total(self):
"""Cek 8 bucket tanggung jawab. Jumlah jam masing-masing bucket
harus cocok dengan nilai expected, dan total semua bucket
harus sama dengan grand total.
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
values = {
'internal': 30.70,
'it_hold': 10.74,
'customer': 31.34,
'user_after_auto_resolve': 29.89,
'needs_verification': 36.72,
'scheduled_meeting': 13.26,
'third_party': 27.22,
'resolved_idle': 0.0,
}
actual = {key: 0.0 for key in values}
for log in ticket.stage_log_ids:
key = log.override_responsibility or log.responsibility
if key in actual:
actual[key] += log.duration_hours or 0.0
for key, expected in values.items():
self.assertAlmostEqual(actual[key], expected, places=2, msg=key)
self.assertAlmostEqual(sum(actual.values()), sum(values.values()), places=2)
def test_acceptance_2_as_of_and_overrides(self):
"""Pastikan tiket punya field first_ack_at dan
first_meaningful_response_at (waktu respon pertama).
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
self.assertTrue(all(hasattr(ticket, field) for field in (
'first_ack_at', 'first_meaningful_response_at',
)))
def test_acceptance_3_bucket_invariant(self):
"""Cek invariant: total jam dari semua bucket harus sama
dengan total jam dari semua stage log. Tidak boleh ada
jam yang hilang atau dobel.
"""
ticket = self._ticket_9658()
if not ticket:
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
bucket_names = {
'internal', 'it_hold', 'customer', 'user_after_auto_resolve',
'needs_verification', 'scheduled_meeting', 'third_party',
'resolved_idle',
}
bucket_total = sum(
log.duration_hours or 0.0
for log in ticket.stage_log_ids
if (log.override_responsibility or log.responsibility) in bucket_names
)
self.assertAlmostEqual(bucket_total, sum(log.duration_hours or 0.0 for log in ticket.stage_log_ids), places=2)

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
"""Package wizard untuk laporan bottleneck tiket Freshservice."""
from . import gmn_freshservice_ticket_bottleneck_wizard_v01

View File

@@ -1,4 +1,9 @@
# -*- 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 re
import base64
@@ -11,7 +16,12 @@ from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days
_logger = logging.getLogger(__name__)
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'
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
@@ -74,6 +84,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
@api.onchange('ticket_id')
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:
self.external_ticket_id = self.ticket_id.external_ticket_id
@@ -86,11 +97,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
return clean
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
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
if not start:
return False, False
@@ -132,6 +147,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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)))
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:
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))