[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 -*- # -*- 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 -*- # -*- 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

View File

@@ -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)."))