Compare commits

...

2 Commits

Author SHA1 Message Date
Madanil
62e545276f [UPD] update logic tiket 2026-10-19 16:46:27 +07:00
Madanil
ef5ba7f159 [UPD] update colot template and logic UI 2026-10-19 10:15:38 +07:00
15 changed files with 746 additions and 423 deletions

View File

@@ -25,7 +25,7 @@ class FreshserviceTicketConversation(models.Model):
sender_type = fields.Selection([ sender_type = fields.Selection([
('agent', 'IT Support (Agent)'), ('agent', 'IT Support (Agent)'),
('customer', 'User (Customer)'), ('customer', 'User (Requester)'),
], string='Tipe Pengirim', compute='_compute_sender_type', store=True) ], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
body_text = fields.Text(string='Isi Pesan') body_text = fields.Text(string='Isi Pesan')

View File

@@ -255,15 +255,36 @@ class FreshserviceTicketStageLog(models.Model):
stage_name = fields.Char(string='Status / Stage', required=True) stage_name = fields.Char(string='Status / Stage', required=True)
responsibility = fields.Selection([ responsibility = fields.Selection([
('internal', 'Internal (Waiting on Us)'), ('internal', 'Agent Active Work'),
('customer', 'Customer (Waiting on User)'), ('it_hold', 'Agent Hold & Discussion'),
('on_hold', 'On Hold (SLA is Paused)'), ('customer', 'Waiting on Requester'),
('third_party', 'Waiting on Third Party (SLA is Paused)'), ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'), ('needs_verification', 'Waiting on User Verification'),
('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('external_activity', 'External Activity (SLA is Paused)'), ('third_party', 'Waiting on Third Party'),
('resolved_idle', 'Resolved (Ticket Closed/Idle)'), ('resolved_idle', 'Resolved / Idle'),
], string='Tanggung Jawab', required=True, default='internal', index=True) # Legacy values are retained for historical audit snapshots.
('on_hold', 'On Hold (Legacy)'),
('internal_discussion', 'Internal Discussion (Legacy)'),
('external_activity', 'External Activity (Legacy)'),
], string='Task Owner', required=True, default='internal', index=True)
effective_status = fields.Char(string='Effective Status', index=True)
classification_basis = fields.Char(string='Classification Basis')
needs_verification = fields.Boolean(string='User Verification Required')
delay_cause = fields.Selection([
('it', 'Agent'), ('user', 'User'), ('none', 'None'),
], string='Delay Cause', default='none')
automatic_responsibility = fields.Selection(related='responsibility', readonly=True)
override_responsibility = fields.Selection([
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'),
], string='Task Owner Override')
override_source = fields.Char(string='Override Source')
override_reviewer = fields.Char(string='Override Reviewer')
override_at = fields.Datetime(string='Override Date')
actor_name = fields.Char(string='Pelaku / Actor') actor_name = fields.Char(string='Pelaku / Actor')
actor_type = fields.Selection([ actor_type = fields.Selection([

View File

@@ -36,7 +36,7 @@ class FreshserviceTicket(models.Model):
requester_name = fields.Char(string='Pemohon (User)', tracking=True) requester_name = fields.Char(string='Pemohon (User)', tracking=True)
requester_email = fields.Char(string='Email Pemohon') requester_email = fields.Char(string='Email Pemohon')
responder_name = fields.Char(string='Agen Penanggung Jawab (IT)', tracking=True) responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True)
department_name = fields.Char(string='Departemen') department_name = fields.Char(string='Departemen')
# ========================================== # ==========================================
@@ -62,9 +62,13 @@ class FreshserviceTicket(models.Model):
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True) ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
due_by = fields.Datetime(string='Batas Waktu (Due Date)') due_by = fields.Datetime(string='Batas Waktu (Due Date)')
first_responded_at = fields.Datetime(string='Waktu First Response') first_responded_at = fields.Datetime(string='Waktu First Response')
first_ack_at = fields.Datetime(string='Waktu First Acknowledgement', readonly=True)
first_meaningful_response_at = fields.Datetime(
string='Waktu First Meaningful Response', readonly=True,
)
resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)') resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)')
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, readonly=True) is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True)
is_detail_synced = fields.Boolean( is_detail_synced = fields.Boolean(
string='Detail Lengkap Tersinkron', string='Detail Lengkap Tersinkron',
default=False, default=False,
@@ -112,24 +116,58 @@ class FreshserviceTicket(models.Model):
digits=(12, 2) digits=(12, 2)
) )
total_agent_time_display = fields.Char( total_agent_time_display = fields.Char(
string='Agen (Waiting on Us)', string='Agent Responsibility',
compute='_compute_time_metrics', compute='_compute_time_metrics',
store=True store=True
) )
total_customer_time_days = fields.Integer( total_customer_time_days = fields.Integer(
string='Total Waktu di User (Hari)', string='Requester Time (Days)',
compute='_compute_time_metrics', compute='_compute_time_metrics',
store=True store=True
) )
total_customer_time_hours = fields.Float( total_customer_time_hours = fields.Float(
string='Total Waktu di User (Jam)', string='Requester Time (Hours)',
compute='_compute_time_metrics', compute='_compute_time_metrics',
store=True, store=True,
digits=(12, 2) digits=(12, 2)
) )
total_customer_time_display = fields.Char( total_customer_time_display = fields.Char(
string='User (Waiting on User)', string='Requester Responsibility',
compute='_compute_time_metrics',
store=True
)
shared_wait_time_days = fields.Integer(
string='Scheduled Meeting (Days)',
compute='_compute_time_metrics',
store=True
)
shared_wait_time_hours = fields.Float(
string='Scheduled Meeting (Hours)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
shared_wait_time_display = fields.Char(
string='Scheduled Meeting',
compute='_compute_time_metrics',
store=True
)
third_party_time_days = fields.Integer(
string='Waiting on Third Party (Hari)',
compute='_compute_time_metrics',
store=True
)
third_party_time_hours = fields.Float(
string='Waiting on Third Party (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
third_party_time_display = fields.Char(
string='Waiting on Third Party',
compute='_compute_time_metrics', compute='_compute_time_metrics',
store=True store=True
) )
@@ -146,16 +184,16 @@ class FreshserviceTicket(models.Model):
digits=(12, 2) digits=(12, 2)
) )
resolution_time_display = fields.Char( resolution_time_display = fields.Char(
string='Total Waktu Selesai', string='Total Resolution Time',
compute='_compute_time_metrics', compute='_compute_time_metrics',
store=True store=True
) )
current_responsibility = fields.Selection([ current_responsibility = fields.Selection([
('internal', 'Waiting on Us (Internal)'), ('internal', 'Agent Active Work'),
('customer', 'Waiting on User (Customer)'), ('customer', 'Waiting on Requester'),
('closed', 'Tiket Selesai'), ('closed', 'Completed'),
], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True) ], string='Current Responsibility', compute='_compute_time_metrics', store=True)
# Relasi # Relasi
stage_log_ids = fields.One2many( stage_log_ids = fields.One2many(
@@ -171,7 +209,7 @@ class FreshserviceTicket(models.Model):
@api.depends( @api.depends(
'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced', 'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced',
'stage_log_ids.duration_days', 'stage_log_ids.duration_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):
for rec in self: for rec in self:
@@ -190,14 +228,24 @@ class FreshserviceTicket(models.Model):
rec.first_response_time_display = "Belum Ada Respon" rec.first_response_time_display = "Belum Ada Respon"
# 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender. # 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender.
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'internal') # Mengikuti Aturan Fair Responsibility:
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer') # - IT Aktif / IT Hold / On Hold / Internal Discussion: Agen (Waiting on Us)
# - User / User Setelah Auto Resolve: Customer (Waiting on User)
# - Perlu Verifikasi: Dicatat tersendiri / dapat masuk rentang estimasi
# - Scheduled Meeting: Bersama
# - Third Party: Pihak Ketiga
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('internal', 'it_hold', 'on_hold', 'internal_discussion'))
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('customer', 'user_after_auto_resolve', 'needs_verification'))
shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'scheduled_meeting')
third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('third_party', 'external_activity'))
agent_days = round(agent_hours / 8.0, 2) agent_days = round(agent_hours / 8.0, 2)
cust_days = round(cust_hours / 8.0, 2) cust_days = round(cust_hours / 8.0, 2)
shared_wait_days = round(shared_wait_hours / 8.0, 2)
third_party_days = round(third_party_hours / 8.0, 2)
# Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT. # Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT.
total_working_hours = sum(l.working_hours for l in rec.stage_log_ids) total_working_hours = sum(l.working_hours for l in rec.stage_log_ids)
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'resolved_idle') resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'resolved_idle')
rec.total_agent_time_hours = round(agent_hours, 2) rec.total_agent_time_hours = round(agent_hours, 2)
rec.total_agent_time_days = round(agent_days) rec.total_agent_time_days = round(agent_days)
@@ -207,6 +255,14 @@ class FreshserviceTicket(models.Model):
rec.total_customer_time_days = round(cust_days) rec.total_customer_time_days = round(cust_days)
rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
rec.shared_wait_time_hours = round(shared_wait_hours, 2)
rec.shared_wait_time_days = round(shared_wait_days)
rec.shared_wait_time_display = format_duration_days(round(shared_wait_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
rec.third_party_time_hours = round(third_party_hours, 2)
rec.third_party_time_days = round(third_party_days)
rec.third_party_time_display = format_duration_days(round(third_party_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
# 3. Hitung Resolution Time # 3. Hitung Resolution Time
if rec.ticket_created_at and rec.resolved_at: if rec.ticket_created_at and rec.resolved_at:
delta_res = rec.resolved_at - rec.ticket_created_at delta_res = rec.resolved_at - rec.ticket_created_at
@@ -269,6 +325,28 @@ class FreshserviceTicket(models.Model):
} }
} }
def _ball_in_court_check(self, effective_status, last_msg, last_actor_type):
"""
Aturan B: Ball-in-court analysis.
Mengevaluasi apakah stage hold/waiting memiliki potensi misklasifikasi
karena Agent sedang menunggu konfirmasi user atau sebaliknya.
Mengembalikan (is_needs_verification, basis_snippet).
"""
if not last_msg:
return False, ""
msg_lower = last_msg.lower()
confirmation_keywords = [
'mohon konfirmasi', 'konfirmasi', 'mohon dicek', 'silakan dicek',
'apakah sudah', 'apakah bisa', 'menunggu konfirmasi', 'apakah kendala',
'mohon info', 'tolong konfirmasi', 'konfirmasinya'
]
has_req = any(kw in msg_lower for kw in confirmation_keywords) or ('?' in last_msg and last_actor_type == 'Agent')
if has_req and (effective_status in ('Waiting on Internal Discussion', 'On Hold') or 'hold' in str(effective_status).lower()):
words = last_msg.strip().split()
snippet = ' '.join(words[:10]) + ('...' if len(words) > 10 else '')
return True, f"Agent meminta konfirmasi: \"{snippet}\""
return False, ""
def action_sync_from_freshservice(self): def action_sync_from_freshservice(self):
""" """
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi). Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
@@ -289,9 +367,9 @@ class FreshserviceTicket(models.Model):
return return
get_param = self.env['ir.config_parameter'].sudo().get_param get_param = self.env['ir.config_parameter'].sudo().get_param
base_url = get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
api_key = get_param('freshservice_ticket_audit.freshservice_api_key', '') api_key = get_param('gmn_freshservice.freshservice_api_key', '')
use_mock = get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) use_mock = False
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
payload = client.fetch_ticket_full_data(self.external_ticket_id) payload = client.fetch_ticket_full_data(self.external_ticket_id)
@@ -389,7 +467,13 @@ class FreshserviceTicket(models.Model):
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM) conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
log_vals_list = [] log_vals_list = []
# Hanya perubahan status eksplisit yang mengubah state berikutnya.
effective_status = 'Open'
current_state = 'internal' current_state = 'internal'
last_public_message = False
last_message_actor_type = False
first_ack_dt = False
first_meaningful_dt = False
meeting_wait_until = False meeting_wait_until = False
meeting_start_dt = False meeting_start_dt = False
meeting_waiting_for_followup = False meeting_waiting_for_followup = False
@@ -504,59 +588,128 @@ class FreshserviceTicket(models.Model):
else: else:
stage_name = 'Aktivitas' stage_name = 'Aktivitas'
# 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine. # 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification
# Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state. # ==============================================================================
# Aturan A: Hanya event "set Status as X" yang mengubah effective_status.
# Event non-status (Timesheet, Note, Assignment, Reply) MEWARISI status sebelumnya.
# Aturan B: Ball-in-court check untuk IT_HOLD / Waiting on End User.
# Deteksi 2 segmen PERLU_VERIFIKASI (Sep 18 09:38-Sep 21 14:05 & Oct 6 09:28-Oct 9 10:44).
# Aturan C: Resolved oleh sistem/auto-rule → USER_SETELAH_AUTO_RESOLVE.
# Aturan D: Scheduled Meeting → BERSAMA dengan penanda delay_cause.
# Aturan E: First response substantif terpisah dari auto-ack.
# ==============================================================================
combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower() combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower()
is_timesheet = any(k in combined_text for k in ('timesheet', 'time spent', 'create timesheet'))
if is_timesheet:
stage_name = 'Create Timesheet'
responsibility = 'external_activity'
else:
status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower() status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower()
explicit_status = 'set status as' in status_text explicit_status = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I))
is_requester_reopen_workflow = (
actor_type == 'System'
and 'reopen tickets when the requester responds' in status_text
)
requester_email = (self.requester_email or '').strip().lower()
is_agent_reply_to_requester = (
actor_type.strip().lower() == 'agent'
and bool(re.search(r'replied\s+to', status_text, re.I))
)
is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I))
vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text)) vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text))
confirmation_to_user = bool(re.search(r'(mohon|please|harap|kindly).{0,100}(konfirmasi|confirmation|keputusan|decision|approve|persetujuan)', status_text, re.I)) is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I))
if is_agent_assignment:
# Penugasan/perubahan agent mengalihkan tanggung jawab kembali ke internal (Waiting on Us). # Update pesan terakhir jika ada balasan/catatan publik
current_state = 'internal' if matched_msg:
elif vendor_external or (explicit_status and 'waiting on third party' in status_text): last_public_message = matched_msg
# Vendor Escalation=External adalah trigger resmi meski bukan field Status. last_message_actor_type = actor_type
elif 'replied' in content_main.lower() or 'reply' in content_main.lower():
last_public_message = content_main
last_message_actor_type = actor_type
# Rekam First Response (Aturan E)
if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()):
is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting'))
if not first_ack_dt:
first_ack_dt = start_dt_obj
if not is_auto_ack and not first_meaningful_dt:
first_meaningful_dt = start_dt_obj
classification_basis = "Status warisan: %s" % effective_status
needs_verification_flag = False
delay_cause = 'none'
# Update effective_status hanya jika ada perubahan eksplisit
if vendor_external or (explicit_status and 'waiting on third party' in status_text):
effective_status = 'Waiting on Third Party'
current_state = 'third_party' current_state = 'third_party'
classification_basis = 'Status Freshservice: Waiting on Third Party'
elif explicit_status and 'waiting on internal discussion' in status_text: elif explicit_status and 'waiting on internal discussion' in status_text:
current_state = 'internal_discussion' effective_status = 'Waiting on Internal Discussion'
current_state = 'it_hold'
classification_basis = 'Status Freshservice: Waiting on Internal Discussion'
elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')): elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')):
effective_status = 'Waiting on End User'
current_state = 'customer' current_state = 'customer'
classification_basis = 'Status Freshservice: Waiting on End User'
elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')): elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')):
current_state = 'on_hold' effective_status = 'On Hold'
current_state = 'it_hold'
classification_basis = 'Status Freshservice: On Hold'
elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')): elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')):
is_auto_resolve = actor_type == 'System' or 'auto-resolve' in combined_text or 'workflow' in combined_text
effective_status = 'Resolved'
if is_auto_resolve:
current_state = 'user_after_auto_resolve'
classification_basis = 'Auto-resolve oleh sistem (menunggu respon user)'
else:
current_state = 'resolved_idle' current_state = 'resolved_idle'
classification_basis = 'Status Freshservice: Resolved'
elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')): elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')):
effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open'
current_state = 'internal' current_state = 'internal'
elif is_agent_reply_to_requester: classification_basis = 'Status Freshservice: %s' % effective_status
# Agent sudah membalas requester; giliran berikutnya
# menunggu respons user dan dibebankan ke end user (customer).
current_state = 'customer'
# Balasan requester tidak serta-merta mengalihkan pembebanan ke internal
# sebelum tiket resmi di-reopen oleh workflow atau agent.
elif actor_type == 'Agent' and current_state in ('on_hold', 'internal_discussion') and confirmation_to_user:
# Pengecualian: note Agent yang eksplisit meminta keputusan/konfirmasi user.
current_state = 'customer'
responsibility = current_state responsibility = current_state
# 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent. # Aturan B: status hold tidak otomatis berarti IT menunggu internal.
# Bila pesan publik terakhir dari Agent meminta konfirmasi user, klasifikasi
# dibuat eksplisit sebagai perlu verifikasi dan menyimpan kutipan pemicunya.
is_ball_req, basis_ball = self._ball_in_court_check(
effective_status, last_public_message, last_message_actor_type,
)
if is_ball_req:
responsibility = 'needs_verification'
needs_verification_flag = True
classification_basis = basis_ball
# Penanganan khusus Create Timesheet: MEWARISI status efektif (Aturan A)
if 'timesheet' in combined_text:
stage_name = 'Create Timesheet'
# Timesheet tidak mengubah responsibility, tetap memakai responsibility dari status efektif
classification_basis = 'Timesheet (mewarisi status efektif %s)' % effective_status
# Penanganan balasan Agent: tidak membalik tanggung jawab jika status masih di user (Aturan A)
if actor_type == 'Agent' and any(k in content_main.lower() for k in ('replied', 'reply')) and current_state == 'customer':
responsibility = 'customer'
classification_basis = 'Balasan Agent (mewarisi status Waiting on End User)'
# 4a. Smart Detection: Undangan Zoom / Meeting & Aturan Pembatalan Meeting (Cancel Meeting)
is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or '')) is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or ''))
is_meeting_cancelled = False
# Cek apakah setelah link meeting dikirim, meeting dibatalkan atau digantikan oleh status resmi:
# 1. Perubahan status resmi selain "Waiting on Internal Discussion" (misal: In Progress, Waiting on End User, On Hold, Resolved).
# 2. Atau terdapat kata kunci pembatalan eksplisit (cancel/batal meeting/zoom/call).
if meeting_waiting_for_followup and not is_meeting_invitation:
has_explicit_status_change = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) or vendor_external or is_agent_assignment
is_internal_disc = 'waiting on internal discussion' in status_text.lower()
cancel_kw = bool(re.search(r'\b(batal|cancel|reschedule|tidak\s+jadi)\b.{0,30}\b(meeting|zoom|call|gmeet)\b', full_activity_content or '', re.I))
if has_explicit_status_change and not is_internal_disc:
# An explicit status transition ends the shared meeting phase,
# but it is not a cancellation by itself. The next status or
# activity is allowed to determine the normal responsibility.
meeting_waiting_for_followup = False
meeting_start_dt = False
meeting_wait_until = False
interval_meeting_start = False
if cancel_kw:
# Only explicit cancellation wording marks the phase as
# cancelled in the audit trail.
is_meeting_cancelled = True
stage_name = f'Meeting Cancelled / {stage_name}'
meeting_waiting_for_followup = False
meeting_start_dt = False
meeting_wait_until = False
interval_meeting_start = False
if is_meeting_invitation: if is_meeting_invitation:
parsed_meeting_dt = False parsed_meeting_dt = False
time_match = meeting_time_pattern.search(full_activity_content or '') time_match = meeting_time_pattern.search(full_activity_content or '')
@@ -571,6 +724,10 @@ class FreshserviceTicket(models.Model):
meeting_start_dt = parsed_meeting_dt meeting_start_dt = parsed_meeting_dt
meeting_waiting_for_followup = True meeting_waiting_for_followup = True
meeting_wait_until = False meeting_wait_until = False
# Aktivitas pengiriman link dibebankan kepada pengirimnya.
# Jika Agent yang mengirim, stage log ini berwarna Internal (merah)
# di Excel. Fase Waiting Together baru dimulai setelah aktivitas ini.
responsibility = 'internal' if actor_type == 'Agent' else current_state
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until: if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
meeting_wait_until = False meeting_wait_until = False
@@ -581,22 +738,27 @@ class FreshserviceTicket(models.Model):
k in (content_main or '').lower() for k in ('replied', 'reply', 'note') k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
) )
if is_meeting_invitation: if is_meeting_invitation:
responsibility = 'scheduled_meeting' # Pengiriman link adalah pekerjaan pembuat link. Jangan mengubahnya
# menjadi Waiting Together; status meeting dipakai aktivitas berikutnya.
responsibility = 'internal' if actor_type == 'Agent' else current_state
meeting_wait_until = False meeting_wait_until = False
elif meeting_waiting_for_followup and start_dt_obj: elif is_meeting_cancelled:
# No parsable Time: waits together until Agent follow-up. With Time:, # Tanggung jawab kembali ke status operasional saat ini (current_state)
# the period from meeting start until that same follow-up is internal. responsibility = current_state
if meeting_start_dt and start_dt_obj >= meeting_start_dt: elif (
responsibility = 'internal' meeting_waiting_for_followup
if is_agent_followup: and not is_meeting_invitation
meeting_waiting_for_followup = False and 'waiting on internal discussion' in status_text.lower()
meeting_start_dt = False ):
meeting_wait_until = start_dt_obj # Setelah link meeting dikirim, status ini masih merupakan
else: # fase Scheduled Meeting dan tidak membatalkan state meeting.
responsibility = 'scheduled_meeting'
elif meeting_waiting_for_followup:
# Keep the complete interval shared while the ticket remains in
# Internal Discussion or Scheduled Meeting. Do not split at the
# scheduled meeting start; the next explicit status/activity
# determines the new responsibility.
responsibility = 'scheduled_meeting' responsibility = 'scheduled_meeting'
if not meeting_start_dt and is_agent_followup:
meeting_waiting_for_followup = False
meeting_wait_until = start_dt_obj
# 5. Set First Responder jika belum terdeteksi # 5. Set First Responder jika belum terdeteksi
if not self.first_responder_name and actor_type == 'Agent': if not self.first_responder_name and actor_type == 'Agent':
@@ -606,35 +768,34 @@ class FreshserviceTicket(models.Model):
log_values = { log_values = {
'stage_name': stage_name, 'stage_name': stage_name,
'responsibility': responsibility, 'responsibility': responsibility,
'effective_status': effective_status,
'classification_basis': classification_basis,
'needs_verification': needs_verification_flag,
'delay_cause': delay_cause,
'actor_name': actor_name, 'actor_name': actor_name,
'actor_type': actor_type, 'actor_type': actor_type,
'activity_content': full_activity_content or stage_name, 'activity_content': full_activity_content or stage_name,
'start_date': start_dt, 'start_date': start_dt,
'end_date': end_dt, 'end_date': end_dt,
} }
# Split every affected interval at the scheduled start precisely: # Keep one continuous shared-time interval while the effective status is
# invitation → meeting = shared SLA-paused wait; meeting → next activity = internal follow-up. # Internal Discussion or Scheduled Meeting. The interval changes only
if interval_meeting_start and end_dt_obj and start_dt_obj < interval_meeting_start < end_dt_obj: # when the next activity/status explicitly changes responsibility.
waiting_values = dict(
log_values,
responsibility='scheduled_meeting',
end_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'),
)
followup_values = dict(
log_values,
stage_name='Scheduled Meeting / Follow-up',
responsibility='internal',
activity_content='%s\n[Auto split: meeting start → next Agent follow-up]' % (full_activity_content or stage_name),
start_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'),
)
log_vals_list.extend([(0, 0, waiting_values), (0, 0, followup_values)])
else:
log_vals_list.append((0, 0, log_values)) log_vals_list.append((0, 0, log_values))
ticket_write_vals = {}
if first_ack_dt:
ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S')
if first_meaningful_dt:
ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S')
if log_vals_list: if log_vals_list:
# Baru ganti snapshot setelah daftar audit trail pengganti siap. # Baru ganti snapshot setelah daftar audit trail pengganti siap.
self.stage_log_ids.unlink() self.stage_log_ids.unlink()
self.write({'stage_log_ids': log_vals_list}) ticket_write_vals['stage_log_ids'] = log_vals_list
self.write(ticket_write_vals)
elif ticket_write_vals:
self.write(ticket_write_vals)
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong. # 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
conv_vals_list = [] conv_vals_list = []
@@ -657,7 +818,7 @@ class FreshserviceTicket(models.Model):
'riwayat percakapan lama dipertahankan.', self.external_ticket_id, 'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
) )
mode_text = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "LIVE FRESHSERVICE API" mode_text = "LIVE FRESHSERVICE API"
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'display_notification', 'tag': 'display_notification',
@@ -669,25 +830,6 @@ class FreshserviceTicket(models.Model):
} }
} }
def action_toggle_mock_mode(self):
"""Toggle instan mode antara Mock dan Live API"""
param_obj = self.env['ir.config_parameter'].sudo()
current = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
new_val = not current
param_obj.set_param('freshservice_ticket_audit.freshservice_use_mock', str(new_val))
status_label = "MOCK DATA (Simulasi)" if new_val else "LIVE API FRESHSERVICE"
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Mode Berhasil Diubah',
'message': f'Sistem sekarang beralih ke: {status_label}. Klik tombol "Tarik Data Freshservice" untuk menyegarkan data.',
'type': 'info',
'sticky': False,
}
}
# ========================================================================= # =========================================================================
# AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION # AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION
# ========================================================================= # =========================================================================
@@ -711,14 +853,14 @@ class FreshserviceTicket(models.Model):
param_obj = self.env['ir.config_parameter'].sudo() param_obj = self.env['ir.config_parameter'].sudo()
if not limit: if not limit:
try: try:
limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200) limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
except (ValueError, TypeError): except (ValueError, TypeError):
limit = 200 limit = 200
if limit <= 0: if limit <= 0:
limit = 200 limit = 200
now_ts = fields.Datetime.now().timestamp() now_ts = fields.Datetime.now().timestamp()
last_sync = param_obj.get_param('freshservice_ticket_audit.last_list_sync_time') last_sync = param_obj.get_param('gmn_freshservice.last_list_sync_time')
if last_sync: if last_sync:
try: try:
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir # Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
@@ -726,14 +868,14 @@ class FreshserviceTicket(models.Model):
return 0 return 0
except Exception: except Exception:
pass pass
param_obj.set_param('freshservice_ticket_audit.last_list_sync_time', str(now_ts)) param_obj.set_param('gmn_freshservice.last_list_sync_time', str(now_ts))
base_url = param_obj.get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
api_key = param_obj.get_param('freshservice_ticket_audit.freshservice_api_key', '') api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '')
use_mock = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) use_mock = False
try: try:
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=False)
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id) tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
except Exception as e: except Exception as e:
_logger.warning("Auto-fetch Freshservice gagal: %s", e) _logger.warning("Auto-fetch Freshservice gagal: %s", e)
@@ -884,10 +1026,10 @@ class FreshserviceTicket(models.Model):
except Exception as e: except Exception as e:
_logger.warning("Auto fetch saat buka menu tiket: %s", e) _logger.warning("Auto fetch saat buka menu tiket: %s", e)
action = self.env['ir.actions.act_window']._for_xml_id('freshservice_ticket_audit.action_freshservice_ticket') action = self.env['ir.actions.act_window']._for_xml_id('gmn_freshservice.action_freshservice_ticket')
param_obj = self.env['ir.config_parameter'].sudo() param_obj = self.env['ir.config_parameter'].sudo()
try: try:
sync_limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200) sync_limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200)
if sync_limit > 0: if sync_limit > 0:
action['limit'] = sync_limit action['limit'] = sync_limit
except Exception: except Exception:

View File

@@ -8,24 +8,25 @@ class ResConfigSettings(models.TransientModel):
freshservice_url = fields.Char( freshservice_url = fields.Char(
string='Freshservice URL', string='Freshservice URL',
config_parameter='freshservice_ticket_audit.freshservice_url', config_parameter='gmn_freshservice.freshservice_url',
default='https://ptglobalmultimedianetwork.freshservice.com' default='https://ptglobalmultimedianetwork.freshservice.com'
) )
freshservice_api_key = fields.Char( freshservice_api_key = fields.Char(
string='Freshservice API Key', string='Freshservice API Key',
config_parameter='freshservice_ticket_audit.freshservice_api_key' config_parameter='gmn_freshservice.freshservice_api_key'
) )
freshservice_sync_limit = fields.Integer( freshservice_sync_limit = fields.Integer(
string='Limit Sinkronisasi Tiket', string='Limit Sinkronisasi Tiket',
config_parameter='freshservice_ticket_audit.freshservice_sync_limit', config_parameter='gmn_freshservice.freshservice_sync_limit',
default=200, default=200,
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).' help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
) )
freshservice_use_mock = fields.Boolean( freshservice_use_mock = fields.Boolean(
string='Gunakan Mock Data Engine', string='Gunakan Mock Data Engine',
config_parameter='freshservice_ticket_audit.freshservice_use_mock', config_parameter='gmn_freshservice.freshservice_use_mock',
# default=True, default=False,
help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.' readonly=True,
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
) )
def action_test_freshservice_connection(self): def action_test_freshservice_connection(self):

View File

@@ -31,10 +31,10 @@ class FreshserviceClient:
_agent_cache = {} _agent_cache = {}
_dept_cache = {} _dept_cache = {}
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=True): def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
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 = use_mock self.use_mock = False
def get_agent_name(self, agent_id): def get_agent_name(self, agent_id):
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)""" """Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""

View File

@@ -5,7 +5,7 @@
<field name="name">freshservice.ticket.stage.log.tree</field> <field name="name">freshservice.ticket.stage.log.tree</field>
<field name="model">freshservice.ticket.stage.log</field> <field name="model">freshservice.ticket.stage.log</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Audit Trail &amp; Durasi Status" <list string="Audit Trail &amp; Status Duration"
decoration-danger="responsibility == 'internal' and is_active" decoration-danger="responsibility == 'internal' and is_active"
decoration-warning="responsibility == 'customer' and is_active" decoration-warning="responsibility == 'customer' and is_active"
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"> decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
@@ -13,9 +13,10 @@
<field name="stage_name" widget="badge"/> <field name="stage_name" widget="badge"/>
<field name="responsibility" <field name="responsibility"
widget="badge" widget="badge"
decoration-danger="responsibility == 'internal'" decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
decoration-warning="responsibility == 'customer'" decoration-warning="responsibility == 'customer'"
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/> decoration-info="responsibility == 'scheduled_meeting'"
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
<field name="actor_name"/> <field name="actor_name"/>
<field name="actor_type"/> <field name="actor_type"/>
<field name="start_date" string="Waktu Mulai (UTC)"/> <field name="start_date" string="Waktu Mulai (UTC)"/>
@@ -36,25 +37,37 @@
<form string="Detail Audit Trail"> <form string="Detail Audit Trail">
<sheet> <sheet>
<group> <group>
<group> <group string="Informasi Status &amp; Tanggung Jawab">
<field name="ticket_id" readonly="1"/> <field name="ticket_id" readonly="1"/>
<field name="stage_name" readonly="1"/> <field name="stage_name" readonly="1"/>
<field name="responsibility" readonly="1"/> <field name="effective_status" readonly="1"/>
<field name="responsibility" readonly="1" string="Effective Responsibility"/>
<field name="automatic_responsibility" readonly="1"/>
<field name="actor_name" readonly="1"/> <field name="actor_name" readonly="1"/>
<field name="actor_type" readonly="1"/> <field name="actor_type" readonly="1"/>
</group> </group>
<group> <group string="Durasi Kerja Operasional (08:30-17:30 WIB)">
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/> <field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
<field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/> <field name="end_date" readonly="1" string="Waktu Selesai (UTC)"/>
<field name="working_duration_display" readonly="1" string="Jam Kerja (08:30-17:30 WIB)"/> <field name="working_duration_display" readonly="1" string="Jam Kerja Efektif"/>
<field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/> <field name="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
<field name="duration_display" readonly="1" string="Durasi Kalender"/> <field name="duration_display" readonly="1" string="Durasi Kalender"/>
<field name="duration_days" readonly="1"/>
<!-- Jam di-hide -->
<field name="duration_hours" invisible="1"/>
<field name="is_active" readonly="1"/> <field name="is_active" readonly="1"/>
</group> </group>
</group> </group>
<group string="Klasifikasi &amp; Manual Override (Aturan F)">
<group>
<field name="delay_cause"/>
<field name="needs_verification"/>
<field name="classification_basis"/>
</group>
<group>
<field name="override_responsibility"/>
<field name="override_source"/>
<field name="override_reviewer"/>
<field name="override_at"/>
</group>
</group>
<notebook> <notebook>
<page string="Deskripsi Aktivitas"> <page string="Deskripsi Aktivitas">
<field name="activity_content" readonly="1"/> <field name="activity_content" readonly="1"/>
@@ -70,20 +83,20 @@
<field name="name">freshservice.ticket.stage.log.search</field> <field name="name">freshservice.ticket.stage.log.search</field>
<field name="model">freshservice.ticket.stage.log</field> <field name="model">freshservice.ticket.stage.log</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<search string="Cari Log Aktivitas"> <search string="Search Activity Logs">
<field name="ticket_id"/> <field name="ticket_id"/>
<field name="stage_name"/> <field name="stage_name"/>
<field name="actor_name"/> <field name="actor_name"/>
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/> <filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
<filter name="filter_customer" string="Waiting on User (Customer)" domain="[('responsibility', '=', 'customer')]"/> <filter name="filter_customer" string="Waiting on User (Requester)" domain="[('responsibility', '=', 'customer')]"/>
<filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/> <filter name="filter_on_hold" string="On Hold (SLA Paused)" domain="[('responsibility', '=', 'on_hold')]"/>
<filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/> <filter name="filter_third_party" string="Waiting on Third Party (SLA Paused)" domain="[('responsibility', '=', 'third_party')]"/>
<filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/> <filter name="filter_internal_discussion" string="Internal Discussion (SLA Paused)" domain="[('responsibility', '=', 'internal_discussion')]"/>
<filter name="filter_scheduled_meeting" string="Tunggu Bersama (Scheduled Meeting)" domain="[('responsibility', '=', 'scheduled_meeting')]"/> <filter name="filter_scheduled_meeting" string="Scheduled Meeting (Waiting Together)" domain="[('responsibility', '=', 'scheduled_meeting')]"/>
<filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/> <filter name="filter_external_activity" string="External Activity (SLA Paused)" domain="[('responsibility', '=', 'external_activity')]"/>
<filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/> <filter name="filter_resolved_idle" string="Resolved (Ticket Closed/Idle)" domain="[('responsibility', '=', 'resolved_idle')]"/>
<filter name="filter_sla_paused" string="Semua SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/> <filter name="filter_sla_paused" string="All SLA Paused" domain="[('responsibility', 'in', ['on_hold','third_party','internal_discussion','scheduled_meeting','external_activity'])]"/>
<filter name="filter_active" string="Sedang Berjalan (Aktif)" domain="[('is_active', '=', True)]"/> <filter name="filter_active" string="In Progress (Active)" domain="[('is_active', '=', True)]"/>
<separator/> <separator/>
<group> <group>
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/> <filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>

View File

@@ -9,32 +9,21 @@
<header> <header>
<button name="action_sync_from_freshservice" <button name="action_sync_from_freshservice"
type="object" type="object"
string="Tarik Data Freshservice" string="Sync Freshservice Data"
class="btn-primary" class="btn-primary"
icon="fa-refresh"/> icon="fa-refresh"/>
<button name="action_open_bottleneck_wizard" <button name="action_open_bottleneck_wizard"
type="object" type="object"
string="Laporan Bottleneck (Excel)" string="Bottleneck Report (Excel)"
class="btn-outline-success" class="btn-outline-success"
icon="fa-file-excel-o"/> icon="fa-file-excel-o"/>
<!-- <button name="action_assign_to_me"
type="object"
string="Tugaskan ke Saya"
class="btn-outline-primary"
icon="fa-user-check"/>
<button name="action_toggle_mock_mode"
type="object"
string="Ganti Mode (Mock / Live)"
class="btn-secondary"
icon="fa-exchange"/> -->
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/> <field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
</header> </header>
<sheet> <sheet>
<field name="is_detail_synced" invisible="1"/> <field name="is_detail_synced" invisible="1"/>
<widget name="web_ribbon" title="SIMULASI MOCK DATA" bg_color="text-bg-info" invisible="not is_mock_data"/>
<div class="oe_title"> <div class="oe_title">
<label for="name" string="Judul / Masalah Tiket"/> <label for="name" string="Ticket Subject / Issue"/>
<h1> <h1>
<field name="name" placeholder="Contoh: SAP Client error di finance..."/> <field name="name" placeholder="Contoh: SAP Client error di finance..."/>
</h1> </h1>
@@ -43,47 +32,53 @@
<field name="current_responsibility" widget="badge" <field name="current_responsibility" widget="badge"
decoration-danger="current_responsibility == 'internal'" decoration-danger="current_responsibility == 'internal'"
decoration-warning="current_responsibility == 'customer'" decoration-warning="current_responsibility == 'customer'"
decoration-info="current_responsibility == 'scheduled_meeting'"
decoration-muted="current_responsibility == 'third_party'"
decoration-success="current_responsibility == 'closed'"/> decoration-success="current_responsibility == 'closed'"/>
</div> </div>
</div> </div>
<!-- KPI METRIC CARDS SECTION --> <!-- KPI METRIC CARDS SECTION -->
<div class="row mt-4 mb-4"> <div class="row mt-4 mb-4">
<div class="col-md-3"> <div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-primary"> <div class="card text-center p-3 shadow-sm border-primary">
<small class="text-muted fw-bold text-uppercase">First Response</small> <small class="text-muted fw-bold text-uppercase">First Response</small>
<h4 class="mb-0 text-primary mt-1"> <h4 class="mb-0 text-primary mt-1"><field name="first_response_time_display" readonly="1"/></h4>
<field name="first_response_time_display" readonly="1"/> <small class="text-secondary mt-1">Oleh: <field name="first_responder_name" readonly="1"/></small>
</h4>
<small class="text-secondary mt-1">
Oleh: <field name="first_responder_name" readonly="1"/>
</small>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-danger"> <div class="card text-center p-3 shadow-sm border-danger">
<small class="text-muted fw-bold text-uppercase">Agent</small> <small class="text-muted fw-bold text-uppercase">Agent</small>
<h4 class="mb-0 text-danger mt-1"> <h4 class="mb-0 text-danger mt-1"><field name="total_agent_time_display" readonly="1"/></h4>
<field name="total_agent_time_display" readonly="1"/>
</h4>
<small class="text-secondary mt-1">Waiting on Us</small> <small class="text-secondary mt-1">Waiting on Us</small>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-warning"> <div class="card text-center p-3 shadow-sm border-warning">
<small class="text-muted fw-bold text-uppercase">User</small> <small class="text-muted fw-bold text-uppercase">User</small>
<h4 class="mb-0 text-warning mt-1"> <h4 class="mb-0 text-warning mt-1"><field name="total_customer_time_display" readonly="1"/></h4>
<field name="total_customer_time_display" readonly="1"/>
</h4>
<small class="text-secondary mt-1">Waiting on Customer</small> <small class="text-secondary mt-1">Waiting on Customer</small>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-info">
<small class="text-muted fw-bold text-uppercase">Scheduled Meeting</small>
<h4 class="mb-0 text-info mt-1"><field name="shared_wait_time_display" readonly="1"/></h4>
<small class="text-secondary mt-1">Waiting Together</small>
</div>
</div>
<div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-secondary">
<small class="text-muted fw-bold text-uppercase">Third Party</small>
<h4 class="mb-0 text-secondary mt-1"><field name="third_party_time_display" readonly="1"/></h4>
<small class="text-secondary mt-1">Waiting on Vendor</small>
</div>
</div>
<div class="col-md-2">
<div class="card text-center p-3 shadow-sm border-success"> <div class="card text-center p-3 shadow-sm border-success">
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small> <small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
<h4 class="mb-0 text-success mt-1"> <h4 class="mb-0 text-success mt-1"><field name="resolution_time_display" readonly="1"/></h4>
<field name="resolution_time_display" readonly="1"/>
</h4>
<small class="text-secondary mt-1">Total Resolution</small> <small class="text-secondary mt-1">Total Resolution</small>
</div> </div>
</div> </div>
@@ -115,9 +110,10 @@
decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"> decoration-muted="not is_active or responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')">
<field name="stage_name" widget="badge"/> <field name="stage_name" widget="badge"/>
<field name="responsibility" widget="badge" <field name="responsibility" widget="badge"
decoration-danger="responsibility == 'internal'" decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
decoration-warning="responsibility == 'customer'" decoration-warning="responsibility == 'customer'"
decoration-muted="responsibility in ('on_hold','third_party','internal_discussion','scheduled_meeting','external_activity','resolved_idle')"/> decoration-info="responsibility == 'scheduled_meeting'"
decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/>
<field name="actor_name"/> <field name="actor_name"/>
<field name="actor_type"/> <field name="actor_type"/>
<field name="start_date" string="Waktu Mulai (UTC)"/> <field name="start_date" string="Waktu Mulai (UTC)"/>
@@ -159,7 +155,11 @@
<field name="first_response_time_days" readonly="1"/> <field name="first_response_time_days" readonly="1"/>
<field name="total_agent_time_days" readonly="1"/> <field name="total_agent_time_days" readonly="1"/>
<field name="total_customer_time_days" readonly="1"/> <field name="total_customer_time_days" readonly="1"/>
<field name="shared_wait_time_days" readonly="1"/>
<field name="third_party_time_days" readonly="1"/>
<field name="resolution_time_days" readonly="1"/> <field name="resolution_time_days" readonly="1"/>
<field name="shared_wait_time_hours" invisible="1"/>
<field name="third_party_time_hours" invisible="1"/>
<!-- Field jam disembunyikan (hide) --> <!-- Field jam disembunyikan (hide) -->
<field name="first_response_time_hours" invisible="1"/> <field name="first_response_time_hours" invisible="1"/>
<field name="total_agent_time_hours" invisible="1"/> <field name="total_agent_time_hours" invisible="1"/>
@@ -180,7 +180,7 @@
<field name="name">freshservice.ticket.tree</field> <field name="name">freshservice.ticket.tree</field>
<field name="model">freshservice.ticket</field> <field name="model">freshservice.ticket</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Daftar Tiket Freshservice" <list string="Freshservice Tickets"
decoration-danger="current_responsibility == 'internal'" decoration-danger="current_responsibility == 'internal'"
decoration-warning="current_responsibility == 'customer'" decoration-warning="current_responsibility == 'customer'"
decoration-muted="status in ('resolved', 'closed')"> decoration-muted="status in ('resolved', 'closed')">
@@ -204,6 +204,8 @@
decoration-success="current_responsibility == 'closed'"/> decoration-success="current_responsibility == 'closed'"/>
<field name="total_agent_time_display"/> <field name="total_agent_time_display"/>
<field name="total_customer_time_display"/> <field name="total_customer_time_display"/>
<field name="shared_wait_time_display" optional="show"/>
<field name="third_party_time_display" optional="show"/>
<field name="first_response_time_display"/> <field name="first_response_time_display"/>
<field name="responder_name"/> <field name="responder_name"/>
<field name="requester_name"/> <field name="requester_name"/>
@@ -297,17 +299,17 @@
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/> <filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
<separator/> <separator/>
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/> <filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
<filter name="filter_waiting_user" string="Waiting on Customer (User)" domain="[('current_responsibility', '=', 'customer')]"/> <filter name="filter_waiting_user" string="Waiting on Requester (User)" domain="[('current_responsibility', '=', 'customer')]"/>
<separator/> <separator/>
<filter name="filter_open_progress" string="Tiket Aktif (Belum Selesai)" domain="[('status', 'in', ['open', 'pending'])]"/> <filter name="filter_open_progress" string="Active Tickets (Not Completed)" domain="[('status', 'in', ['open', 'pending'])]"/>
<filter name="filter_resolved" string="Selesai (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/> <filter name="filter_resolved" string="Completed (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
<separator/> <separator/>
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/> <filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/> <filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
<group> <group>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/> <filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
<filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/> <filter name="group_by_responsibility" string="Tanggung Jawab" context="{'group_by': 'current_responsibility'}"/>
<filter name="group_by_responder" string="Agen (IT)" context="{'group_by': 'responder_name'}"/> <filter name="group_by_responder" string="Agent (IT)" context="{'group_by': 'responder_name'}"/>
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/> <filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
</group> </group>
</search> </search>

View File

@@ -7,7 +7,7 @@
<field name="inherit_id" ref="base.res_config_settings_view_form"/> <field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//form" position="inside"> <xpath expr="//form" position="inside">
<app data-string="Freshservice Audit" string="Freshservice Audit" name="freshservice_ticket_audit"> <app data-string="Freshservice Audit" string="Freshservice Audit" name="gmn_freshservice">
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block"> <block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda"> <setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
<field name="freshservice_url"/> <field name="freshservice_url"/>
@@ -21,12 +21,11 @@
<field name="freshservice_sync_limit"/> <field name="freshservice_sync_limit"/>
</setting> </setting>
<setting id="freshservice_mock_setting" help="Gunakan mock data sesuai format resmi Freshservice API untuk testing tanpa API Key asli"> <setting id="freshservice_connection_setting" help="Uji koneksi ke Live Freshservice API">
<field name="freshservice_use_mock"/>
<div class="mt8"> <div class="mt8">
<button name="action_test_freshservice_connection" <button name="action_test_freshservice_connection"
type="object" type="object"
string="Tes Koneksi / Cek Status" string="Tes Koneksi Live API"
class="btn-primary" class="btn-primary"
icon="fa-plug"/> icon="fa-plug"/>
</div> </div>
@@ -42,6 +41,6 @@
<field name="res_model">res.config.settings</field> <field name="res_model">res.config.settings</field>
<field name="view_mode">form</field> <field name="view_mode">form</field>
<field name="target">main</field> <field name="target">main</field>
<field name="context">{'module': 'freshservice_ticket_audit'}</field> <field name="context">{'module': 'gmn_freshservice'}</field>
</record> </record>
</odoo> </odoo>

View File

@@ -43,6 +43,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
date_from = fields.Date(string='Dari Tanggal Dibuat') date_from = fields.Date(string='Dari Tanggal Dibuat')
date_to = fields.Date(string='Sampai Tanggal Dibuat') date_to = fields.Date(string='Sampai Tanggal Dibuat')
as_of = fields.Datetime(
string='Cut-off As-Of (UTC)',
help='Jika diisi, semua durasi stage log dipotong sampai timestamp ini. '
'Timestamp laporan ditampilkan dalam WIB (UTC+7).',
)
status_filter = fields.Selection([ status_filter = fields.Selection([
('all', 'Semua Status'), ('all', 'Semua Status'),
@@ -80,6 +85,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
clean = re.sub(r'\s+', ' ', clean).strip() clean = re.sub(r'\s+', ' ', clean).strip()
return clean return clean
def _effective_responsibility(self, log):
"""Return the reviewed classification when present, otherwise the automatic one."""
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."""
start = log.start_date
if not start:
return False, False
if report_as_of and start >= report_as_of:
return False, False
end = log.end_date or report_as_of
if report_as_of and (not end or end > report_as_of):
end = report_as_of
if not end or end <= start:
return False, False
return start, end
def _ensure_ticket_details(self, ticket): def _ensure_ticket_details(self, ticket):
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export.""" """Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
if self.auto_sync_detail: if self.auto_sync_detail:
@@ -207,6 +230,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter' 'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
}) })
scheduled_meeting_fmt = workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1,
'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter'
})
kpi_box_label = workbook.add_format({ kpi_box_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True, 'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1' 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
@@ -255,13 +283,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
summary_html = self._build_single_ticket_sheets( summary_html = self._build_single_ticket_sheets(
workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
) )
else: else:
summary_html = self._build_ranking_sheets( summary_html = self._build_ranking_sheets(
workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
) )
workbook.close() workbook.close()
@@ -309,7 +339,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
def _build_single_ticket_sheets( def _build_single_ticket_sheets(
self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
): ):
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal.""" """Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI') ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
@@ -324,7 +355,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws1.set_column('I:I', 14) ws1.set_column('I:I', 14)
# Header Title # Header Title
ws1.merge_range('B2:H2', f'LAPORAN ANALISIS TIKET #{t.external_ticket_id}', title_fmt) ws1.merge_range('B2:H2', f'TICKET ANALYSIS REPORT #{t.external_ticket_id}', title_fmt)
ws1.write( ws1.write(
'B3', 'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}', f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
@@ -332,11 +363,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
) )
# Info Header Tiket # Info Header Tiket
ws1.merge_range('B5:H5', 'Informasi Tiket', section_fmt) ws1.merge_range('B5:H5', 'Ticket Information', section_fmt)
ws1.write('B6', 'ID Freshservice', td_fmt) ws1.write('B6', 'ID Freshservice', td_fmt)
ws1.write('C6', f'#{t.external_ticket_id}', td_fmt) ws1.write('C6', f'#{t.external_ticket_id}', td_fmt)
ws1.write('D6', 'Status Saat Ini', td_fmt) ws1.write('D6', 'Current Status', td_fmt)
st_fmt = status_formats.get(t.status, td_center_fmt) st_fmt = status_formats.get(t.status, td_center_fmt)
ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt) ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt)
@@ -348,14 +379,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws1.write('D8', 'Departemen', td_fmt) ws1.write('D8', 'Departemen', td_fmt)
ws1.write('E8', t.department_name or '-', td_fmt) ws1.write('E8', t.department_name or '-', td_fmt)
ws1.write('B9', 'Agen IT Penanggung Jawab', td_fmt) ws1.write('B9', 'Agent IT Penanggung Jawab', td_fmt)
ws1.write('C9', t.responder_name or '-', td_fmt) ws1.write('C9', t.responder_name or '-', td_fmt)
ws1.write('D9', 'Prioritas', td_fmt) ws1.write('D9', 'Prioritas', td_fmt)
ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt) ws1.write('E9', dict(t._fields['priority'].selection).get(t.priority, t.priority or '-'), td_fmt)
ws1.write('B10', 'Waktu Dibuat', td_fmt) ws1.write('B10', 'Created At', td_fmt)
ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt) ws1.write('C10', str(t.ticket_created_at or '-'), td_fmt)
ws1.write('D10', 'Waktu Selesai', td_fmt) ws1.write('D10', 'Resolved At', td_fmt)
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt) ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
# KPI BOXES # KPI BOXES
@@ -404,292 +435,394 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF', 'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
'align': 'center', 'valign': 'vcenter' 'align': 'center', 'valign': 'vcenter'
}) })
# Warna KPI mengikuti responsibility: internal merah, customer orange,
# scheduled meeting biru, dan third party abu-abu.
kpi_internal_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA'
})
kpi_customer_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F'
})
kpi_meeting_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9'
})
kpi_third_party_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7'
})
kpi_internal_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA',
'align': 'center', 'valign': 'vcenter'
})
kpi_customer_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter'
})
kpi_meeting_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9',
'align': 'center', 'valign': 'vcenter'
})
kpi_third_party_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7',
'align': 'center', 'valign': 'vcenter'
})
ws1.write('B13', 'First Response', kpi_wh_label) ws1.write('B13', 'First Response', kpi_wh_label)
ws1.write('C13', 'Total Waktu', kpi_wh_label) ws1.write('C13', 'Total Waktu', kpi_wh_label)
ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_wh_label) ws1.write('D13', 'Agent Responsibility', kpi_internal_label)
ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label) ws1.write('E13', 'Requester Responsibility', kpi_customer_label)
ws1.write('F13', 'Total SLA Paused', kpi_wh_label) ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label)
ws1.write('G13', 'Resolved / Idle', kpi_wh_label) ws1.write('G13', 'Third Party Responsibility', kpi_third_party_label)
ws1.write('H13', 'Catatan Rumus', kpi_wh_label) ws1.write('H13', 'Catatan Rumus', kpi_wh_label)
# Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat) # Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat)
wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0 wh_fr = compute_working_hours(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0.0
wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0 wh_res = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0
report_as_of = fields.Datetime.now() report_as_of = self.as_of or fields.Datetime.now()
wh_it_direct = 0.0 wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0
wh_internal_hold = 0.0 wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 0.0
wh_cust = 0.0
wh_meeting = 0.0
wh_vendor = 0.0
wh_other_paused = 0.0
wh_resolved_idle = 0.0
stage_wh_map = {} stage_wh_map = {}
stage_resp_map = {}
for s in t.stage_log_ids: for s in t.stage_log_ids:
# Segmen aktif dihitung hingga satu timestamp cetak yang konsisten. start, end = self._clipped_stage_interval(s, report_as_of)
s_wh = compute_working_hours(self.env, s.start_date, s.end_date or report_as_of) if not start:
stage_wh_map[s.id] = 0.0
continue
s_wh = compute_working_hours(self.env, start, end)
resp = self._effective_responsibility(s)
stage_wh_map[s.id] = s_wh stage_wh_map[s.id] = s_wh
if s.responsibility == 'internal': stage_resp_map[s.id] = resp
if resp == 'internal':
wh_it_direct += s_wh wh_it_direct += s_wh
elif s.responsibility in ('on_hold', 'internal_discussion'): elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
# Sesuai Aturan 9: On Hold & Internal Discussion murni internal adalah Beban IT wh_it_hold += s_wh
wh_internal_hold += s_wh elif resp == 'customer':
elif s.responsibility == 'customer':
wh_cust += s_wh wh_cust += s_wh
elif s.responsibility == 'scheduled_meeting': elif resp == 'user_after_auto_resolve':
wh_user_auto += s_wh
elif resp == 'needs_verification':
wh_needs_verif += s_wh
elif resp == 'scheduled_meeting':
wh_meeting += s_wh wh_meeting += s_wh
elif s.responsibility == 'third_party': elif resp in ('third_party', 'external_activity'):
wh_vendor += s_wh wh_vendor += s_wh
elif s.responsibility == 'external_activity': elif resp == 'resolved_idle':
wh_other_paused += s_wh
elif s.responsibility == 'resolved_idle':
wh_resolved_idle += s_wh wh_resolved_idle += s_wh
wh_agent = wh_it_direct + wh_internal_hold wh_agent = wh_it_direct + wh_it_hold
wh_paused = wh_meeting + wh_vendor + wh_other_paused wh_requester = wh_cust + wh_user_auto + wh_needs_verif
wh_total = sum(stage_wh_map.values()) wh_total = sum(stage_wh_map.values())
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val) ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
ws1.write('C14', format_working_duration(wh_total), kpi_box_val) ws1.write('C14', format_working_duration(wh_total), kpi_box_val)
ws1.write('D14', format_working_duration(wh_agent), kpi_box_val) ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val)
ws1.write('E14', format_working_duration(wh_cust), kpi_box_val) ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val)
ws1.write('F14', format_working_duration(wh_paused), kpi_box_val) ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val)
ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_val) ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val)
ws1.write('H14', 'Senin-Jumat, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 Jam/Hari)\nDB UTC → +7 WIB', kpi_box_val) ws1.write('H14', 'Monday-Friday, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 hours/day)\nDB UTC → +7 WIB', kpi_box_val)
# REKAPITULASI BEBAN TANGGUNG JAWAB — harus identik dengan total Sheet 2. # RESPONSIBILITY RECAP — must match the Sheet 2 total.
ws1.merge_range('B16:I16', 'REKAPITULASI BEBAN TANGGUNG JAWAB & MATRIKS BOTTLENECK', section_fmt) ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt)
ws1.write('B17', 'Kategori Beban', th_fmt) ws1.write('B17', 'Responsibility Bucket', th_fmt)
ws1.merge_range('C17:F17', 'Keterangan Operasional', th_fmt) ws1.merge_range('C17:F17', 'Operational Description', th_fmt)
ws1.write('G17', 'Jam Kerja', th_fmt) ws1.write('G17', 'Working Hours', th_fmt)
ws1.write('H17', 'Hari Kerja Efektif', th_fmt) ws1.write('H17', 'Effective Workdays', th_fmt)
ws1.write('I17', '% Beban', th_fmt) ws1.write('I17', '% of Load', th_fmt)
recap_rows = [ recap_rows = [
('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / In Progress).', wh_it_direct), ('Agent Active Work', 'Direct Agent handling while the ticket is Open or In Progress.', wh_it_direct),
('Waiting on Us (IT - Internal Hold & Discussion)', 'Tiket ditahan/dianalisa internal IT (Aturan 9: default Beban IT).', wh_internal_hold), ('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold),
('Waiting on User (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust), ('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust),
('Tunggu Bersama (Scheduled Meeting)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting), ('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto),
('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor), ('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif),
('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused), ('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting),
('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle), ('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor),
('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total), ('Resolved / Idle Time', 'Resolved or closed time without a further attributable action.', wh_resolved_idle),
] ]
for recap_row, (label, description, hours) in enumerate(recap_rows, start=18): # xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18.
label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (section_fmt if label == 'Grand Total Umur Tiket' else (sla_paused_fmt or td_center_fmt))) # Ini menghindari satu baris kosong sebelum rekap pertama.
customer_recap_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'left', 'valign': 'vcenter'
})
customer_recap_number_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00'
})
customer_recap_pct_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bg_color': '#FCF3CF',
'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter', 'num_format': '0.00%'
})
for recap_row, (label, description, hours) in enumerate(recap_rows, start=17):
is_customer = label.startswith('Waiting on Requester') or label.startswith('Waiting on User')
label_fmt = (
bn_badge_fmt if label.startswith('Agent ') else
customer_recap_number_fmt if is_customer else
scheduled_meeting_fmt if label.startswith('Scheduled') else
sla_paused_fmt or td_center_fmt
)
ws1.write(recap_row, 1, label, label_fmt) ws1.write(recap_row, 1, label, label_fmt)
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt) ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
ws1.write(recap_row, 6, hours, td_dec_fmt) excel_row = recap_row + 1
ws1.write(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt) ws1.write_number(recap_row, 6, round(hours, 2), td_dec_fmt)
ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_fmt) ws1.write_number(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
ws1.write_formula(recap_row, 8, f'=IF($G$26=0,0,G{excel_row}/$G$26)', td_pct_fmt)
# Grand total: Excel formula rather than a static number.
ws1.write(25, 1, 'Grand Total Ticket Lifetime', section_fmt)
ws1.merge_range(25, 2, 25, 5, 'Total across all eight responsibility buckets.', section_fmt)
ws1.write_formula(25, 6, '=SUM(G18:G25)', td_dec_fmt)
ws1.write_formula(25, 7, '=SUM(H18:H25)', td_dec_fmt)
ws1.write_formula(25, 8, '=SUM(I18:I25)', td_pct_fmt)
# Conservative IT workload range: the maximum includes pending user verification.
ws1.write('B27', 'Estimated Agent Workload Range', section_fmt)
ws1.write('C27', 'Minimum (Agent Active Work + Agent Hold)', td_fmt)
ws1.write_formula('D27', '=G18+G19', td_dec_fmt)
ws1.write('E27', 'Maximum (+ User Verification)', td_fmt)
ws1.write_formula('F27', '=D27+G22', td_dec_fmt)
# Aggregate ranking by the party responsible for the next action.
ws1.merge_range('B29:E29', 'AGGREGATE BOTTLENECK RANKING BY PARTY', section_fmt)
aggregate_rows = [
('Agent', wh_it_direct + wh_it_hold, 'Active work and internal hold.'),
('Requester', wh_cust + wh_user_auto + wh_needs_verif, 'Waiting on requester, auto-resolve & user verification.'),
('Shared', wh_meeting, 'Waiting for a scheduled meeting.'),
('Third Party', wh_vendor, 'Vendor or external party.')
]
if wh_resolved_idle > 0:
aggregate_rows.append(('Resolved / Idle', wh_resolved_idle, 'Resolved or closed idle time.'))
for col_idx, header in enumerate(['Party', 'Working Hours', 'Workdays', '% of Load'], start=1):
ws1.write(29, col_idx, header, th_fmt)
for aggregate_row, (party, hours, note) in enumerate(aggregate_rows, start=30):
ws1.write(aggregate_row, 1, party, td_fmt)
ws1.write_number(aggregate_row, 2, round(hours, 2), td_dec_fmt)
ws1.write_number(aggregate_row, 3, round(hours / 8.0, 2), td_dec_fmt)
ws1.write_formula(aggregate_row, 4, f'=IF($G$26=0,0,C{aggregate_row + 1}/$G$26)', td_pct_fmt)
# TABEL RANKING BOTTLENECK PER STAGE # TABEL RANKING BOTTLENECK PER STAGE
# Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt)
# ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25. headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status']
ws1.merge_range('B28:I28', 'RANKING BOTTLENECK PER STAGE (Diurutkan dari Tahapan Paling Lama)', section_fmt)
headers_bn = ['Rank', 'Tahapan / Status', 'Tanggung Jawab', 'Pengguna', 'Hari Kerja Efektif', 'Jam Kerja', '% Kontribusi', 'Status']
for col_idx, h in enumerate(headers_bn, start=1): for col_idx, h in enumerate(headers_bn, start=1):
ws1.write(29, col_idx, h, th_fmt) ws1.write(36, col_idx, h, th_fmt)
sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True) sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True)
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01) total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
row = 30 row = 37
primary_bn_text = "Tidak terdeteksi delay signifikan."
primary_bn_actor = "-"
primary_bn_stage = "-"
primary_bn_days = 0
for rank_idx, stage in enumerate(sorted_stages, start=1): for rank_idx, stage in enumerate(sorted_stages, start=1):
stage_wh = stage_wh_map.get(stage.id, 0.0) stage_wh = stage_wh_map.get(stage.id, 0.0)
stage_wd_eff = round(stage_wh / 8.0, 2) stage_wd_eff = round(stage_wh / 8.0, 2)
pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0 resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage))
if stage.responsibility == 'internal': if resp == 'internal':
resp_label = 'Waiting on Us (Internal)' resp_label = 'Agent Active Work'
resp_fmt = bn_badge_fmt resp_fmt = bn_badge_fmt
elif stage.responsibility == 'customer': elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
resp_label = 'Waiting on User (Requester)' resp_label = 'Agent Hold & Discussion'
resp_fmt = bn_badge_fmt
elif resp == 'customer':
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt resp_fmt = normal_badge_fmt
elif stage.responsibility == 'on_hold': elif resp == 'user_after_auto_resolve':
resp_label = 'On Hold (Internal Hold / Waiting on Us)' resp_label = 'Waiting on User After Auto-Resolve'
resp_fmt = bn_badge_fmt resp_fmt = normal_badge_fmt
elif stage.responsibility == 'third_party': elif resp == 'needs_verification':
resp_label = 'Waiting on Third Party (SLA is Paused)' resp_label = 'Waiting on User Verification'
resp_fmt = sla_paused_fmt or td_center_fmt resp_fmt = normal_badge_fmt
elif stage.responsibility == 'internal_discussion': elif resp == 'scheduled_meeting':
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)' resp_label = 'Scheduled Meeting (Shared)'
resp_fmt = bn_badge_fmt resp_fmt = scheduled_meeting_fmt
elif stage.responsibility == 'scheduled_meeting': elif resp in ('third_party', 'external_activity'):
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)' resp_label = 'Waiting on Third Party'
resp_fmt = sla_paused_fmt or td_center_fmt
elif stage.responsibility == 'external_activity':
resp_label = 'External Activity (SLA is Paused)'
resp_fmt = sla_paused_fmt or td_center_fmt resp_fmt = sla_paused_fmt or td_center_fmt
elif resp == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
resp_fmt = td_center_fmt
else: else:
resp_label = 'Resolved (Ticket Closed/Idle)' resp_label = 'Waiting on Requester'
resp_fmt = sla_paused_fmt or td_center_fmt resp_fmt = normal_badge_fmt
is_primary = (rank_idx == 1 and stage_wh > 0.0) is_primary = (rank_idx == 1 and stage_wh > 0.0)
ws1.write(row, 1, f"#{rank_idx}", bn_badge_fmt if is_primary else td_center_fmt) ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt)
ws1.write(row, 2, stage.stage_name or '-', td_fmt) ws1.write(row, 2, stage.stage_name or '-', td_fmt)
ws1.write(row, 3, resp_label, resp_fmt) ws1.write(row, 3, resp_label, resp_fmt)
ws1.write(row, 4, stage.actor_name or '-', td_fmt) ws1.write(row, 4, stage.actor_name or '-', td_fmt)
ws1.write(row, 5, stage_wd_eff, td_dec_fmt) ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt)
ws1.write(row, 6, stage_wh, td_dec_fmt) ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt)
ws1.write(row, 7, pct, td_pct_fmt) ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt)
ws1.write(row, 8, 'Sedang Berjalan' if stage.is_active else 'Selesai', td_center_fmt) ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt)
row += 1 row += 1
# KESIMPULAN HIGHLIGHT BOX # SHEET 2: AUDIT TRAIL & CHRONOLOGY
row += 1 ws2 = workbook.add_worksheet('Audit Trail & Chronology')
# ws1.merge_range(row, 1, row, 8, 'KESIMPULAN BOTTLENECK UNTUK BAHAN MEETING', section_fmt) ws2.set_column('A:A', 4)
row += 1
# highlight_fmt = workbook.add_format({
# 'bold': True, 'font_size': 10, 'font_name': 'Calibri',
# 'bg_color': '#FDEDEC', 'font_color': '#78281F', 'border': 1, 'border_color': '#E6B0AA'
# })
# ws1.merge_range(row, 1, row + 1, 8, primary_bn_text, highlight_fmt)
# SHEET 2: KRONOLOGI AUDIT TRAIL
ws2 = workbook.add_worksheet('Audit Trail & Kronologi')
ws2.set_column('A:A', 5)
ws2.set_column('B:B', 6) ws2.set_column('B:B', 6)
ws2.set_column('C:C', 22) ws2.set_column('C:D', 18)
ws2.set_column('D:D', 22) ws2.set_column('E:F', 24)
ws2.set_column('E:E', 22) ws2.set_column('G:G', 28)
ws2.set_column('F:F', 26) ws2.set_column('H:H', 14)
ws2.set_column('G:G', 16) ws2.set_column('I:I', 20)
ws2.set_column('H:H', 22) ws2.set_column('J:K', 16)
ws2.set_column('I:I', 18) ws2.set_column('L:L', 20)
ws2.set_column('J:J', 18) ws2.set_column('M:M', 32)
ws2.set_column('K:K', 35) ws2.set_column('N:N', 16)
ws2.set_column('L:L', 55) ws2.set_column('O:O', 35)
ws2.set_column('P:P', 55)
ws2.merge_range('B2:L2', f'KRONOLOGI LENGKAP AUDIT TRAIL TIKET #{t.external_ticket_id}', title_fmt) ws2.merge_range('B2:P2', f'FULL AUDIT TRAIL CHRONOLOGY — TICKET #{t.external_ticket_id}', title_fmt)
headers_ws2 = [ headers_ws2 = [
'No', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status', 'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Tanggung Jawab', 'Tipe Pengguna', 'Nama Pengguna', 'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name',
'Hari Kerja Efektif', 'Jam Kerja', 'Effective Workdays', 'Working Hours', 'Delay Cause',
'Keterangan Aktivitas', 'Isi Balasan / Pesan' 'Classification Basis', 'User Verification Required',
'Activity Details', 'Reply / Message Content'
] ]
for col_idx, h in enumerate(headers_ws2, start=1): for col_idx, h in enumerate(headers_ws2, start=1):
ws2.write(3, col_idx, h, th_fmt) ws2.write(3, col_idx, h, th_fmt)
row2 = 4
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1):
if log.responsibility == 'internal':
resp_label = 'Waiting on Us (Internal)'
elif log.responsibility == 'customer':
resp_label = 'Waiting on User (Customer)'
elif log.responsibility == 'on_hold':
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
elif log.responsibility == 'third_party':
resp_label = 'Waiting on Third Party (SLA is Paused)'
elif log.responsibility == 'internal_discussion':
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
elif log.responsibility == 'scheduled_meeting':
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
elif log.responsibility == 'external_activity':
resp_label = 'External Activity (SLA is Paused)'
else:
resp_label = 'Resolved (Ticket Closed/Idle)'
msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main:
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
calc_days = compute_calendar_days(self.env, log.start_date, log.end_date or report_as_of)
wh = compute_working_hours(self.env, log.start_date, log.end_date or report_as_of)
wd_eff = round(wh / 8.0, 2)
# Tampilkan waktu dalam WIB (UTC+7) untuk kemudahan baca
from datetime import timedelta as _td from datetime import timedelta as _td
def _to_wib_str(dt): def _to_wib_str(dt):
if not dt: if not dt:
return 'Aktif' return 'Active'
try: try:
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M') return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception: except Exception:
return str(dt) return str(dt)
row2 = 4
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1):
resp = stage_resp_map.get(log.id, self._effective_responsibility(log))
if resp == 'internal':
resp_label = 'Agent Active Work'
resp_fmt = bn_badge_fmt
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
resp_label = 'Agent Hold & Discussion'
resp_fmt = bn_badge_fmt
elif resp == 'customer':
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
elif resp == 'user_after_auto_resolve':
resp_label = 'Waiting on User After Auto-Resolve'
resp_fmt = normal_badge_fmt
elif resp == 'needs_verification':
resp_label = 'Waiting on User Verification'
resp_fmt = normal_badge_fmt
elif resp == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Shared)'
resp_fmt = scheduled_meeting_fmt
elif resp in ('third_party', 'external_activity'):
resp_label = 'Waiting on Third Party'
resp_fmt = sla_paused_fmt or td_center_fmt
elif resp == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
resp_fmt = td_center_fmt
else:
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main:
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
wh = stage_wh_map.get(log.id, 0.0)
wd_eff = round(wh / 8.0, 2)
cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None')
verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No'
ws2.write(row2, 1, idx2, td_center_fmt) ws2.write(row2, 1, idx2, td_center_fmt)
ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt) ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt)
ws2.write(row2, 3, _to_wib_str(log.end_date), td_center_fmt) ws2.write(row2, 3, _to_wib_str(log.end_date), td_center_fmt)
ws2.write(row2, 4, log.stage_name or '-', td_fmt) ws2.write(row2, 4, log.stage_name or '-', td_fmt)
ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt))) ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt)
ws2.write(row2, 6, log.actor_type or '-', td_center_fmt) ws2.write(row2, 6, resp_label, resp_fmt)
ws2.write(row2, 7, log.actor_name or '-', td_fmt) ws2.write(row2, 7, log.actor_type or '-', td_center_fmt)
ws2.write(row2, 8, wd_eff, td_dec_fmt) ws2.write(row2, 8, log.actor_name or '-', td_fmt)
ws2.write(row2, 9, wh, td_dec_fmt) ws2.write_number(row2, 9, wd_eff, td_dec_fmt)
ws2.write(row2, 10, self._strip_html(raw_act_main) or '-', td_fmt) ws2.write_number(row2, 10, round(wh, 2), td_dec_fmt)
ws2.write(row2, 11, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt) ws2.write(row2, 11, cause_label, td_center_fmt)
ws2.write(row2, 12, log.classification_basis or '-', td_fmt)
ws2.write(row2, 13, verif_str, td_center_fmt)
ws2.write(row2, 14, self._strip_html(raw_act_main) or '-', td_fmt)
ws2.write(row2, 15, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
row2 += 1 row2 += 1
# Grand total menggunakan sumber data yang sama dengan Sheet 1 (stage_wh_map). # Grand total using Excel formulas
ws2.merge_range(row2, 1, row2, 7, 'GRAND TOTAL UMUR TIKET', section_fmt) ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt)
ws2.write(row2, 8, round(wh_total / 8.0, 2), td_dec_fmt) ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt)
ws2.write(row2, 9, wh_total, td_dec_fmt) ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', td_dec_fmt)
ws2.merge_range(row2, 10, row2, 11, 'Sama dengan Sheet 1: seluruh stage log termasuk SLA Paused dan Resolved / Idle.', td_fmt) ws2.merge_range(row2, 11, row2, 15, 'Matches Sheet 1: all stage logs calculated using effective business hours.', td_fmt)
# SHEET 3: RIWAYAT PERCAKAPAN # SHEET 3: CONVERSATIONS & NOTES
ws3 = workbook.add_worksheet('Percakapan & Respon') ws3 = workbook.add_worksheet('Conversations & Notes')
ws3.set_column('A:A', 5) ws3.set_column('A:A', 5)
ws3.set_column('B:B', 8) ws3.set_column('B:B', 8)
ws3.set_column('C:C', 18) ws3.set_column('C:C', 20)
ws3.set_column('D:D', 22) ws3.set_column('D:D', 22)
ws3.set_column('E:E', 24) ws3.set_column('E:E', 24)
ws3.set_column('F:F', 55) ws3.set_column('F:F', 55)
ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt) ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt)
headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan'] headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content']
for col_idx, h in enumerate(headers_ws3, start=1): for col_idx, h in enumerate(headers_ws3, start=1):
ws3.write(3, col_idx, h, th_fmt) ws3.write(3, col_idx, h, th_fmt)
row3 = 4 row3 = 4
for idx3, conv in enumerate(t.conversation_ids.sorted('created_at'), start=1): for idx3, conv in enumerate(t.conversation_ids.sorted('created_at'), start=1):
sender_type_label = 'User (Requester)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent' sender_type_label = 'Requester (User)' if (conv.sender_type == 'customer' or conv.incoming) else 'Agent (IT)'
conv_wib_str = _to_wib_str(conv.created_at)
ws3.write(row3, 1, idx3, td_center_fmt) ws3.write(row3, 1, idx3, td_center_fmt)
ws3.write(row3, 2, str(conv.created_at or '-'), td_center_fmt) ws3.write(row3, 2, conv_wib_str, td_center_fmt)
ws3.write(row3, 3, sender_type_label, normal_badge_fmt if conv.incoming else bn_badge_fmt) ws3.write(row3, 3, sender_type_label, normal_badge_fmt if conv.incoming else bn_badge_fmt)
ws3.write(row3, 4, conv.user_name or '-', td_fmt) ws3.write(row3, 4, conv.user_name or '-', td_fmt)
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt) ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
row3 += 1 row3 += 1
# Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard # HTML summary for wizard pop-up dialog
html_out = f""" html_out = f"""
<div class="alert alert-info border shadow-sm"> <div class="alert alert-info border shadow-sm">
<h4 class="alert-heading fw-bold mb-2">📊 Hasil Analisis Bottleneck Tiket #{t.external_ticket_id}</h4> <h4 class="alert-heading fw-bold mb-2">📊 Ticket Bottleneck Analysis #{t.external_ticket_id}</h4>
<p class="mb-1"><strong>Subjek:</strong> {t.name or '-'}</p> <p class="mb-1"><strong>Subject:</strong> {t.name or '-'}</p>
<p class="mb-2"><strong>Pemohon:</strong> {t.requester_name or '-'} | <strong>Departemen:</strong> {t.department_name or '-'} | <strong>Agen IT:</strong> {t.responder_name or '-'}</p> <p class="mb-2"><strong>Requester:</strong> {t.requester_name or '-'} | <strong>Department:</strong> {t.department_name or '-'} | <strong>Agent Agent:</strong> {t.responder_name or '-'}</p>
<hr class="my-2"/> <hr class="my-2"/>
<div class="row text-center mb-3"> <div class="row text-center mb-3">
<div class="col-md-3"> <div class="col-md-3">
<div class="bg-light p-2 rounded border"> <div class="bg-light p-2 rounded border">
<small class="text-muted d-block">TOTAL WAKTU</small> <small class="text-muted d-block">TOTAL DURATION</small>
<strong class="text-dark fs-5">{t.resolution_time_days} Hari</strong> <strong class="text-dark fs-5">{t.resolution_time_days} Days</strong>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="bg-light p-2 rounded border border-danger"> <div class="bg-light p-2 rounded border border-danger">
<small class="text-danger d-block fw-bold">WAITING ON US (IT)</small> <small class="text-danger d-block fw-bold">AGENT RESPONSIBILITY</small>
<strong class="text-danger fs-5">{t.total_agent_time_days} Hari</strong> <strong class="text-danger fs-5">{t.total_agent_time_days} Days</strong>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="bg-light p-2 rounded border border-warning"> <div class="bg-light p-2 rounded border border-warning">
<small class="text-warning d-block fw-bold">WAITING ON USER</small> <small class="text-warning d-block fw-bold">REQUESTER RESPONSIBILITY</small>
<strong class="text-warning fs-5">{t.total_customer_time_days} Hari</strong> <strong class="text-warning fs-5">{t.total_customer_time_days} Days</strong>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="bg-light p-2 rounded border border-primary"> <div class="bg-light p-2 rounded border border-primary">
<small class="text-primary d-block fw-bold">FIRST RESPONSE</small> <small class="text-primary d-block fw-bold">FIRST RESPONSE</small>
<strong class="text-primary fs-5">{t.first_response_time_days} Hari</strong> <strong class="text-primary fs-5">{t.first_response_time_days} Days</strong>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
""" """
return html_out
# <div class="alert alert-danger mb-0"> # <div class="alert alert-danger mb-0">
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/> # <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
# {primary_bn_text} # {primary_bn_text}
@@ -699,10 +832,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
def _build_ranking_sheets( def _build_ranking_sheets(
self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt, self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats,
sla_paused_fmt=None, scheduled_meeting_fmt=None
): ):
"""Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim).""" """Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim)."""
ws = workbook.add_worksheet('Ranking Bottleneck Tiket') scheduled_meeting_fmt = scheduled_meeting_fmt or sla_paused_fmt or td_center_fmt
ws = workbook.add_worksheet('Ticket Bottleneck Ranking')
ws.set_column('A:A', 5) ws.set_column('A:A', 5)
ws.set_column('B:B', 8) ws.set_column('B:B', 8)
ws.set_column('C:C', 14) ws.set_column('C:C', 14)
@@ -721,20 +856,20 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws.set_column('P:P', 16) ws.set_column('P:P', 16)
ws.set_column('Q:Q', 16) ws.set_column('Q:Q', 16)
ws.merge_range('B2:Q2', 'LAPORAN RANKING BOTTLENECK & EVALUASI SLA TIKET (BAHAN MEETING)', title_fmt) ws.merge_range('B2:Q2', 'BOTTLENECK RANKING & SLA EVALUATION REPORT', title_fmt)
ws.write( ws.write(
'B3', 'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}', f'Printed: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Printed by: {self.env.user.name}',
subtitle_fmt subtitle_fmt
) )
headers = [ headers = [
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT', 'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent',
'Status', 'Status',
'Total Hari Kalender', 'Total Jam Kerja (08:30-17:30)', 'Total Hari Kerja Efektif', 'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays',
'Waktu IT (Hari Kalender)', 'Waktu IT (Jam Kerja)', 'Agent Time (Calendar Days)', 'Agent Time (Working Hours)',
'Waktu User (Hari Kalender)', 'Waktu User (Jam Kerja)', 'User Time (Calendar Days)', 'User Time (Working Hours)',
'Bottleneck Utama (Stage)', 'Durasi Bottleneck (Hari Kalender)', 'Durasi Bottleneck (Jam Kerja)' 'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)'
] ]
for col_idx, h in enumerate(headers, start=1): for col_idx, h in enumerate(headers, start=1):
ws.write(4, col_idx, h, th_fmt) ws.write(4, col_idx, h, th_fmt)
@@ -753,7 +888,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
top_wh = 0.0 top_wh = 0.0
if top_stage: if top_stage:
top_stage_name = top_stage[0].stage_name or '-' top_stage_name = top_stage[0].stage_name or '-'
top_resp = 'Internal (IT)' if top_stage[0].responsibility == 'internal' else 'Customer (User)' top_resp = 'Agent Active Work' if top_stage[0].responsibility == 'internal' else 'Waiting on Requester'
top_days = top_stage[0].duration_days top_days = top_stage[0].duration_days
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date) top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
@@ -763,16 +898,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
t_wh_cust = 0.0 t_wh_cust = 0.0
for s in t.stage_log_ids: for s in t.stage_log_ids:
s_wh = compute_working_hours(self.env, s.start_date, s.end_date) s_wh = compute_working_hours(self.env, s.start_date, s.end_date)
if s.responsibility == 'internal': resp = s.override_responsibility or s.responsibility
if resp in ('internal', 'it_hold', 'on_hold', 'internal_discussion'):
t_wh_agent += s_wh t_wh_agent += s_wh
elif s.responsibility == 'customer': elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'):
t_wh_cust += s_wh t_wh_cust += s_wh
# on_hold: SLA paused, tidak dihitung di IT maupun User
t_wd_eff = round(t_wh_total / 8.0, 2) t_wd_eff = round(t_wh_total / 8.0, 2)
if rank_idx <= 5: if rank_idx <= 5:
slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Hari / {t_wh_total:.1f} Jam Kerja)") slowest_tickets.append(f"#{t.external_ticket_id} ({t.name[:25]}... - {t.resolution_time_days} Days / {t_wh_total:.1f} Working Hours)")
ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt) ws.write(row, 1, f"#{rank_idx}", bn_badge_fmt if rank_idx <= 3 else td_center_fmt)
ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt) ws.write(row, 2, f"#{t.external_ticket_id}", td_center_fmt)
@@ -795,7 +930,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
row += 1 row += 1
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET # SHEET 2: DETAIL STAGE LOG SEMUA TIKET
ws2 = workbook.add_worksheet('Detail Stage Log Semua Tiket') ws2 = workbook.add_worksheet('All Tickets Stage Logs')
ws2.set_column('A:A', 5) ws2.set_column('A:A', 5)
ws2.set_column('B:B', 14) ws2.set_column('B:B', 14)
ws2.set_column('C:C', 22) ws2.set_column('C:C', 22)
@@ -810,13 +945,13 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws2.set_column('L:L', 32) ws2.set_column('L:L', 32)
ws2.set_column('M:M', 50) ws2.set_column('M:M', 50)
ws2.merge_range('B2:M2', 'RINCIAN SELURUH TRANSISI STAGE & DURASI TIKET', title_fmt) ws2.merge_range('B2:M2', 'ALL TICKET STAGE TRANSITIONS & DURATIONS', title_fmt)
headers2 = [ headers2 = [
'ID Tiket', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status', 'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Tanggung Jawab', 'Pengguna', 'Responsibility', 'Actor',
'Hari Kalender', 'Jam Kalender', 'Calendar Days', 'Calendar Hours',
'Jam Kerja (08:30-17:30)', 'Hari Kerja Efektif', 'Working Hours (08:30-17:30)', 'Effective Workdays',
'Keterangan Aktivitas', 'Isi Balasan / Pesan' 'Activity Details', 'Reply / Message Content'
] ]
for col_idx, h in enumerate(headers2, start=1): for col_idx, h in enumerate(headers2, start=1):
ws2.write(3, col_idx, h, th_fmt) ws2.write(3, col_idx, h, th_fmt)
@@ -825,15 +960,23 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
for t in sorted_tickets: for t in sorted_tickets:
for log in t.stage_log_ids.sorted('start_date'): for log in t.stage_log_ids.sorted('start_date'):
if log.responsibility == 'internal': if log.responsibility == 'internal':
resp_label = 'Internal (Waiting on Us)' resp_label = 'Agent Active Work'
elif log.responsibility == 'customer': elif log.responsibility == 'customer':
resp_label = 'Customer (Waiting on User)' resp_label = 'Waiting on Requester'
elif log.responsibility == 'on_hold': elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'):
resp_label = 'On Hold (SLA is Paused)' resp_label = 'Agent Hold & Discussion'
elif log.responsibility == 'third_party': elif log.responsibility == 'third_party':
resp_label = 'Waiting on Third Party (SLA is Paused)' resp_label = 'Waiting on Third Party'
elif log.responsibility == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Shared)'
elif log.responsibility == 'user_after_auto_resolve':
resp_label = 'Waiting on User After Auto-Resolve'
elif log.responsibility == 'needs_verification':
resp_label = 'Waiting on User Verification'
elif log.responsibility == 'resolved_idle':
resp_label = 'Resolved / Idle Time'
else: else:
resp_label = 'Waiting on Internal Discussion (SLA is Paused)' resp_label = 'Waiting on Requester'
msg_body = self._get_stage_conversation_body(log, t) msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or '' raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main: if 'Isi Pesan:' in raw_act_main:
@@ -846,7 +989,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
from datetime import timedelta as _td2 from datetime import timedelta as _td2
def _wib(dt): def _wib(dt):
if not dt: if not dt:
return 'Aktif' return 'Active'
try: try:
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M') return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception: except Exception:
@@ -856,7 +999,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws2.write(row2, 2, _wib(log.start_date), td_center_fmt) ws2.write(row2, 2, _wib(log.start_date), td_center_fmt)
ws2.write(row2, 3, _wib(log.end_date), td_center_fmt) ws2.write(row2, 3, _wib(log.end_date), td_center_fmt)
ws2.write(row2, 4, log.stage_name or '-', td_fmt) ws2.write(row2, 4, log.stage_name or '-', td_fmt)
ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt))) resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'it_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility in ('customer', 'user_after_auto_resolve', 'needs_verification') else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt)))
ws2.write(row2, 5, resp_label, resp_fmt)
ws2.write(row2, 6, log.actor_name or '-', td_fmt) ws2.write(row2, 6, log.actor_name or '-', td_fmt)
ws2.write(row2, 7, calc_log_days, td_num_fmt) ws2.write(row2, 7, calc_log_days, td_num_fmt)
ws2.write(row2, 8, log.duration_hours, td_dec_fmt) ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
@@ -869,15 +1013,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets)) avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
html_out = f""" html_out = f"""
<div class="alert alert-info border shadow-sm"> <div class="alert alert-info border shadow-sm">
<h4 class="alert-heading fw-bold mb-2">📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat</h4> <h4 class="alert-heading fw-bold mb-2">📋 Multi-Ticket Bottleneck Ranking Report Generated</h4>
<p class="mb-1">Total Tiket yang Dievaluasi: <strong>{len(sorted_tickets)} Tiket</strong></p> <p class="mb-1">Total Evaluated Tickets: <strong>{len(sorted_tickets)} Tickets</strong></p>
<p class="mb-2">Rata-rata Waktu Penyelesaian: <strong>{avg_days:.1f} Hari Kalender</strong></p> <p class="mb-2">Average Resolution Time: <strong>{avg_days:.1f} Calendar Days</strong></p>
<hr class="my-2"/> <hr class="my-2"/>
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tiket dengan Durasi Terlama:</p> <p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tickets with Longest Duration:</p>
<ul class="mb-2"> <ul class="mb-2">
{''.join(f'<li>{item}</li>' for item in slowest_tickets)} {''.join(f'<li>{item}</li>' for item in slowest_tickets)}
</ul> </ul>
<small class="text-muted">File Excel siap digunakan sebagai bahan evaluasi dan meeting manajemen.</small> <small class="text-muted">Excel file is ready for team evaluation and management meetings.</small>
</div> </div>
""" """
return html_out return html_out
return html_out