diff --git a/gmn_freshservice/__init__.py b/gmn_freshservice/__init__.py index 1c5a144..f83ecc5 100644 --- a/gmn_freshservice/__init__.py +++ b/gmn_freshservice/__init__.py @@ -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 diff --git a/gmn_freshservice/models/__init__.py b/gmn_freshservice/models/__init__.py index b941012..77be9ac 100644 --- a/gmn_freshservice/models/__init__.py +++ b/gmn_freshservice/models/__init__.py @@ -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 diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py index 5e3016f..c8e25d6 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py @@ -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' diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py index 34f5c13..e505910 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py @@ -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) diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py index 5b4e76a..9e15e5f 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py @@ -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 { diff --git a/gmn_freshservice/models/gmn_res_config_settings_v01.py b/gmn_freshservice/models/gmn_res_config_settings_v01.py index 6bc148b..a10e7d0 100644 --- a/gmn_freshservice/models/gmn_res_config_settings_v01.py +++ b/gmn_freshservice/models/gmn_res_config_settings_v01.py @@ -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: diff --git a/gmn_freshservice/services/__init__.py b/gmn_freshservice/services/__init__.py index 64f6e74..156ac9d 100644 --- a/gmn_freshservice/services/__init__.py +++ b/gmn_freshservice/services/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- +"""Package services: berisi client untuk koneksi ke Freshservice API.""" from . import gmn_freshservice_client_v01 diff --git a/gmn_freshservice/services/gmn_freshservice_client_v01.py b/gmn_freshservice/services/gmn_freshservice_client_v01.py index 0448659..28d8010 100644 --- a/gmn_freshservice/services/gmn_freshservice_client_v01.py +++ b/gmn_freshservice/services/gmn_freshservice_client_v01.py @@ -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!" diff --git a/gmn_freshservice/tests/__init__.py b/gmn_freshservice/tests/__init__.py new file mode 100644 index 0000000..f0f7878 --- /dev/null +++ b/gmn_freshservice/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Package test untuk modul GMN Freshservice.""" +from . import test_responsibility_rules diff --git a/gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc b/gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..30455c5 Binary files /dev/null and b/gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/gmn_freshservice/tests/__pycache__/test_responsibility_rules.cpython-312.pyc b/gmn_freshservice/tests/__pycache__/test_responsibility_rules.cpython-312.pyc new file mode 100644 index 0000000..5973efe Binary files /dev/null and b/gmn_freshservice/tests/__pycache__/test_responsibility_rules.cpython-312.pyc differ diff --git a/gmn_freshservice/tests/test_responsibility_rules.py b/gmn_freshservice/tests/test_responsibility_rules.py new file mode 100644 index 0000000..c220d4d --- /dev/null +++ b/gmn_freshservice/tests/test_responsibility_rules.py @@ -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) diff --git a/gmn_freshservice/wizard/__init__.py b/gmn_freshservice/wizard/__init__.py index 9734d0b..69f42ca 100644 --- a/gmn_freshservice/wizard/__init__.py +++ b/gmn_freshservice/wizard/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- +"""Package wizard untuk laporan bottleneck tiket Freshservice.""" from . import gmn_freshservice_ticket_bottleneck_wizard_v01 diff --git a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py index dd33bbf..299a12a 100644 --- a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py @@ -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)."))