From c0a185478bb37b026fb6a97cf0a832f23381e2e3 Mon Sep 17 00:00:00 2001 From: Madanil Date: Tue, 20 Oct 2026 08:57:07 +0700 Subject: [PATCH] [UPD] add dokumentasi fucntion --- gmn_freshservice/__init__.py | 3 + gmn_freshservice/models/__init__.py | 3 + ...mn_freshservice_ticket_conversation_v01.py | 12 ++- .../gmn_freshservice_ticket_stage_log_v01.py | 15 ++++ .../models/gmn_freshservice_ticket_v01.py | 60 +++++++++----- .../models/gmn_res_config_settings_v01.py | 11 ++- gmn_freshservice/services/__init__.py | 1 + .../services/gmn_freshservice_client_v01.py | 15 ++++ gmn_freshservice/tests/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 214 bytes .../test_responsibility_rules.cpython-312.pyc | Bin 0 -> 4769 bytes .../tests/test_responsibility_rules.py | 78 ++++++++++++++++++ gmn_freshservice/wizard/__init__.py | 1 + ...eshservice_ticket_bottleneck_wizard_v01.py | 24 +++++- 14 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 gmn_freshservice/tests/__init__.py create mode 100644 gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 gmn_freshservice/tests/__pycache__/test_responsibility_rules.cpython-312.pyc create mode 100644 gmn_freshservice/tests/test_responsibility_rules.py 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 0000000000000000000000000000000000000000..30455c5a212f273db2b9273e3aa89bb8a3d02ac5 GIT binary patch literal 214 zcmX@j%ge<81j|ld&2j?Lk3k$5V1hC}3ji6@8B!Qh7;_kM8KW2(87i4HnO`yjg*6#( zNtUD*m&6yP78m5_6=x=8=46&s#ut_5q!#;WGTmY*Vg@Q($?zFu*e{0^tC;+h{CovN zOE4Xio|_k+o1X$y9baYy=A{8m$|z1PD$7hxjR6^591|a(nU`4-AFo$Xd5gmaqTH^C Y185+~3B@4B2WCb_#%B!bMQlJ00JzRNssI20 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5973efe09a7195680fdc0599ded5f6d0d9351f81 GIT binary patch literal 4769 zcmcInU2GKB6~1?7cRgOOS+5P4AO9>i1UAMS3=M(=J0>PUr3Hnb5XmwfO=j-y&U$ud zbMNe8J8KEGM6nSOq-g?`P)kJBCW2N-=|ieMH0o2TzU<4 z^LcfYK$DWypebikE2?J;3e}aY#p1!>sT7G?I`7lVazxb=x*8>8P_l>?Eht8wz*tIP zkVUcL3nQ;M}vqcd2DS+EAt;=xb(abwsHpQ1{ZDJoxnNMS^EVQ^&) z6Er^v|1SfA1PxnVWp#4-?1-VEX8}ua0YvjhLEgM*H?V`A9O8l{2OWXOauMoxi0kjyRg+NO`#`$&|wo z&y7t-5Z*mFHf4uGGG}yl&5w_)2gd zx}atFa=aXT2cU3;Bgng~Na+RS78^921mn3S-sGo;1Xt=i78C7eGG-A9$}78w%DQIb ze%r_O@l$@Ypk{tU&*)Z6pkSiN4ltrnH3juPMvMgGouagnH-)@O_~?nP-vq?cUQIBL zMM0cH)o{zD7pGsWgtwH#TT0zem%0wW|3WFeWj1`gBps(~pwlue>Y>8H!Utz^uvn^z z(8!agZIif&Sp@J}6`Py2kr-)$CTc#-uLZO^P15SM;5BJdC<=$rB~cZ0qzY$1hbP6N zsMWtFT&(k-)in7KI&%Q}`iuT_z}?MKIfhvXY`~x9TC*bY`HQ{+V)$ZL(N`45 zgfYRf3V)j4!HNVk0U>Jj6L1MC>=G757YBT(D5UwdtVkXru)B7IMgY4>aWVkNgjaNg zU%s}pZa`}&inl^HYxauK#ZH)i)iGAYo2TeYw|M2n+*{wnd`W-dLHcVAw;Dad$G{CL zoLvwxs5nWoCe|TJu05RJ(NLb=aTIJeso zrltsICQZ%|V(HnW-C(73s>wNpTH|(OZLkLBYmiU5jRWxoa)zQ3;KK|{zK!WtUK^yX zkl7L&)pOiF?M66wlE{Xcgt5$S1$RWLu8|caXRldKdiEMEPo2%d!Sa;Ze%&G&W=DWy z3AGLy8IxI0y^x2)i!7h91Fp-A)zS5Q{eXK3^b!6ej*@XZ0B+3b4Lh+V&(pFx=n!?2 z%P~7S56gR^1 z7%7hBOip$+}Y4xZEdT9+`hBgwY|FKq3Y&nwLN+_Qr{e$ z3fx_bw)f6Pdf!Z3WtY1zvsZUqPfP_$k=|;{#!Acf4_dZQ4ON@hRhoOt&Al_tL$_Nh zeM9BGA*c;EUrt<@tTwH`%wB7_$|{|G<<7pD&V9E}mYeqdtYNMWt=%yvp^mM;9Qpak zFMc$AbZY4G2H0CA)Kw03UEljw_nVL3mfnlJFP1`GABKK7C!&s>ccf57>L^Pc*Vy&F zzv+Lo|Hc!uoqK+N^4;}+YX3v~A3J9s{eJ1h$&%FZp>*oYdtuZZXFMo-CpgsaTgJqk z-5e(VDHL7?uMf=dEnIv7Pd^YB!z^A92=jW$;ws^!DGXtZa9+~HLkm3bv5J>-h5%1T zIg2gZGoN`6eDixjypDf>u;W9{fJ2(Z*23F_miei0$32x4QyeI(R}z8+_A zeRH(?pHO&N{4VW&&`2W%vBnL2L9`Zhd`bj;7SBr%O{@|xd|k&OEb;ZX$*48m4)_zfQtRR z%=c%9T<;-gl?*%_m)b3T9NH(2KB$83srey!;W4?2x$-I5APCP~eVceZ68DgbO&3WIt;64ibrh{A@24OeWUe_uy3|R(^2|=y%GIX(|oZ}q9pD%1D zh%e5gs=sMo5d7bp7f~QG$4mDl>_36;1=jhW#rNU?f9PLLYy4-ivteiWF?jLU$+Bju zu(t?*zQ_43vAAkxGG>-?%XK=wjod-}0c1-D+;{x|eFlEHZ)cpy=0uF~U7rv8?gtT$ Se;(M2&*H1*eZ+HT_`d-kEU^y& literal 0 HcmV?d00001 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)."))