[UPD] update logic tiket

This commit is contained in:
Madanil
2026-10-19 16:46:27 +07:00
parent ef5ba7f159
commit 62e545276f
8 changed files with 574 additions and 338 deletions

View File

@@ -43,6 +43,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
date_from = fields.Date(string='Dari 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([
('all', 'Semua Status'),
@@ -80,6 +85,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
clean = re.sub(r'\s+', ' ', clean).strip()
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):
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
if self.auto_sync_detail:
@@ -332,7 +355,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws1.set_column('I:I', 14)
# 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(
'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
@@ -340,11 +363,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
)
# 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('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)
ws1.write('E6', dict(t._fields['status'].selection).get(t.status, t.status or '-'), st_fmt)
@@ -356,14 +379,14 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws1.write('D8', 'Departemen', 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('D9', 'Prioritas', 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('D10', 'Waktu Selesai', td_fmt)
ws1.write('D10', 'Resolved At', td_fmt)
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
# KPI BOXES
@@ -452,296 +475,354 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
})
ws1.write('B13', 'First Response', kpi_wh_label)
ws1.write('C13', 'Total Waktu', kpi_wh_label)
ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_internal_label)
ws1.write('E13', 'Waiting on User (Requester)', kpi_customer_label)
ws1.write('D13', 'Agent Responsibility', kpi_internal_label)
ws1.write('E13', 'Requester Responsibility', kpi_customer_label)
ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label)
ws1.write('G13', 'Third Party', kpi_third_party_label)
ws1.write('G13', 'Third Party Responsibility', kpi_third_party_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)
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
report_as_of = fields.Datetime.now()
wh_it_direct = 0.0
wh_internal_hold = 0.0
wh_cust = 0.0
wh_meeting = 0.0
wh_vendor = 0.0
wh_other_paused = 0.0
wh_resolved_idle = 0.0
report_as_of = self.as_of or fields.Datetime.now()
wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0
wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 0.0
stage_wh_map = {}
stage_resp_map = {}
for s in t.stage_log_ids:
# Segmen aktif dihitung hingga satu timestamp cetak yang konsisten.
s_wh = compute_working_hours(self.env, s.start_date, s.end_date or report_as_of)
start, end = self._clipped_stage_interval(s, 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
if s.responsibility == 'internal':
stage_resp_map[s.id] = resp
if resp == 'internal':
wh_it_direct += s_wh
elif s.responsibility in ('on_hold', 'internal_discussion'):
# Sesuai Aturan 9: On Hold & Internal Discussion murni internal adalah Beban IT
wh_internal_hold += s_wh
elif s.responsibility == 'customer':
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
wh_it_hold += s_wh
elif resp == 'customer':
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
elif s.responsibility == 'third_party':
elif resp in ('third_party', 'external_activity'):
wh_vendor += s_wh
elif s.responsibility == 'external_activity':
wh_other_paused += s_wh
elif s.responsibility == 'resolved_idle':
elif resp == 'resolved_idle':
wh_resolved_idle += s_wh
wh_agent = wh_it_direct + wh_internal_hold
wh_paused = wh_meeting + wh_vendor + wh_other_paused
wh_agent = wh_it_direct + wh_it_hold
wh_requester = wh_cust + wh_user_auto + wh_needs_verif
wh_total = sum(stage_wh_map.values())
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
ws1.write('C14', format_working_duration(wh_total), kpi_box_val)
ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val)
ws1.write('E14', format_working_duration(wh_cust), kpi_customer_val)
ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val)
ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_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.
ws1.merge_range('B16:I16', 'REKAPITULASI BEBAN TANGGUNG JAWAB & MATRIKS BOTTLENECK', section_fmt)
ws1.write('B17', 'Kategori Beban', th_fmt)
ws1.merge_range('C17:F17', 'Keterangan Operasional', th_fmt)
ws1.write('G17', 'Jam Kerja', th_fmt)
ws1.write('H17', 'Hari Kerja Efektif', th_fmt)
ws1.write('I17', '% Beban', th_fmt)
# RESPONSIBILITY RECAP — must match the Sheet 2 total.
ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt)
ws1.write('B17', 'Responsibility Bucket', th_fmt)
ws1.merge_range('C17:F17', 'Operational Description', th_fmt)
ws1.write('G17', 'Working Hours', th_fmt)
ws1.write('H17', 'Effective Workdays', th_fmt)
ws1.write('I17', '% of Load', th_fmt)
recap_rows = [
('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / 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),
('Waiting on User (Requester)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust),
('Scheduled Meeting (Waiting Together)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting),
('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor),
('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused),
('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle),
('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total),
('Agent Active Work', 'Direct Agent handling while the ticket is Open or In Progress.', wh_it_direct),
('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold),
('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust),
('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto),
('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif),
('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting),
('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor),
('Resolved / Idle Time', 'Resolved or closed time without a further attributable action.', wh_resolved_idle),
]
# xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18.
# 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):
label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (scheduled_meeting_fmt if label.startswith('Scheduled Meeting') else (section_fmt if label == 'Grand Total Umur Tiket' else (sla_paused_fmt or td_center_fmt))))
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.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
ws1.write(recap_row, 6, hours, td_dec_fmt)
ws1.write(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_fmt)
excel_row = recap_row + 1
ws1.write_number(recap_row, 6, round(hours, 2), td_dec_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
# Rekap berakhir di Excel row 25. Row 26 adalah pemisah.
# Title di Excel row 27 (index 26), header di Excel row 28 (index 27),
# dan data mulai di Excel row 29 (index 28) secara berurutan tanpa baris kosong.
ws1.merge_range('B27:I27', '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']
ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt)
headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status']
for col_idx, h in enumerate(headers_bn, start=1):
ws1.write(27, 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)
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
row = 28
primary_bn_text = "Tidak terdeteksi delay signifikan."
primary_bn_actor = "-"
primary_bn_stage = "-"
primary_bn_days = 0
row = 37
for rank_idx, stage in enumerate(sorted_stages, start=1):
stage_wh = stage_wh_map.get(stage.id, 0.0)
stage_wd_eff = round(stage_wh / 8.0, 2)
pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0
if stage.responsibility == 'internal':
resp_label = 'Waiting on Us (Internal)'
resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage))
if resp == 'internal':
resp_label = 'Agent Active Work'
resp_fmt = bn_badge_fmt
elif stage.responsibility == 'customer':
resp_label = 'Waiting on User (Requester)'
# Customer selalu orange/kuning, bukan merah.
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 stage.responsibility == 'on_hold':
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
resp_fmt = bn_badge_fmt
elif stage.responsibility == 'third_party':
resp_label = 'Waiting on Third Party (SLA is Paused)'
resp_fmt = sla_paused_fmt or td_center_fmt
elif stage.responsibility == 'internal_discussion':
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
resp_fmt = bn_badge_fmt
elif stage.responsibility == 'scheduled_meeting':
resp_label = 'Scheduled Meeting (Waiting Together - SLA is Paused)'
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 stage.responsibility == 'external_activity':
resp_label = 'External Activity (SLA is Paused)'
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 = 'Resolved (Ticket Closed/Idle)'
resp_fmt = sla_paused_fmt or td_center_fmt
resp_label = 'Waiting on Requester'
resp_fmt = normal_badge_fmt
is_primary = (rank_idx == 1 and stage_wh > 0.0)
# Nomor ranking utama mengikuti warna tanggung jawab bottleneck.
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, 3, resp_label, resp_fmt)
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
ws1.write(row, 5, stage_wd_eff, td_dec_fmt)
ws1.write(row, 6, stage_wh, td_dec_fmt)
ws1.write(row, 7, pct, td_pct_fmt)
ws1.write(row, 8, 'Sedang Berjalan' if stage.is_active else 'Selesai', td_center_fmt)
ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt)
ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt)
ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt)
ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt)
row += 1
# KESIMPULAN HIGHLIGHT BOX
row += 1
# ws1.merge_range(row, 1, row, 8, 'KESIMPULAN BOTTLENECK UNTUK BAHAN MEETING', section_fmt)
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)
# SHEET 2: AUDIT TRAIL & CHRONOLOGY
ws2 = workbook.add_worksheet('Audit Trail & Chronology')
ws2.set_column('A:A', 4)
ws2.set_column('B:B', 6)
ws2.set_column('C:C', 22)
ws2.set_column('D:D', 22)
ws2.set_column('E:E', 22)
ws2.set_column('F:F', 26)
ws2.set_column('G:G', 16)
ws2.set_column('H:H', 22)
ws2.set_column('I:I', 18)
ws2.set_column('J:J', 18)
ws2.set_column('K:K', 35)
ws2.set_column('L:L', 55)
ws2.set_column('C:D', 18)
ws2.set_column('E:F', 24)
ws2.set_column('G:G', 28)
ws2.set_column('H:H', 14)
ws2.set_column('I:I', 20)
ws2.set_column('J:K', 16)
ws2.set_column('L:L', 20)
ws2.set_column('M:M', 32)
ws2.set_column('N:N', 16)
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 = [
'No', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
'Tanggung Jawab', 'Tipe Pengguna', 'Nama Pengguna',
'Hari Kerja Efektif', 'Jam Kerja',
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name',
'Effective Workdays', 'Working Hours', 'Delay Cause',
'Classification Basis', 'User Verification Required',
'Activity Details', 'Reply / Message Content'
]
for col_idx, h in enumerate(headers_ws2, start=1):
ws2.write(3, col_idx, h, th_fmt)
from datetime import timedelta as _td
def _to_wib_str(dt):
if not dt:
return 'Active'
try:
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
return str(dt)
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 (Requester)'
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 = 'Scheduled Meeting (Waiting Together - SLA is Paused)'
elif log.responsibility == 'external_activity':
resp_label = 'External Activity (SLA is Paused)'
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 = 'Resolved (Ticket Closed/Idle)'
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()
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)
wh = stage_wh_map.get(log.id, 0.0)
wd_eff = round(wh / 8.0, 2)
# Tampilkan waktu dalam WIB (UTC+7) untuk kemudahan baca
from datetime import timedelta as _td
def _to_wib_str(dt):
if not dt:
return 'Aktif'
try:
return (dt + _td(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
return str(dt)
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, 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, 4, log.stage_name or '-', td_fmt)
resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility == 'customer' else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' else (sla_paused_fmt or td_center_fmt)))
ws2.write(row2, 5, resp_label, resp_fmt)
ws2.write(row2, 6, log.actor_type or '-', td_center_fmt)
ws2.write(row2, 7, log.actor_name or '-', td_fmt)
ws2.write(row2, 8, wd_eff, td_dec_fmt)
ws2.write(row2, 9, wh, td_dec_fmt)
ws2.write(row2, 10, self._strip_html(raw_act_main) or '-', td_fmt)
ws2.write(row2, 11, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
ws2.write(row2, 5, log.effective_status or log.stage_name or '-', td_fmt)
ws2.write(row2, 6, resp_label, resp_fmt)
ws2.write(row2, 7, log.actor_type or '-', td_center_fmt)
ws2.write(row2, 8, log.actor_name or '-', td_fmt)
ws2.write_number(row2, 9, wd_eff, td_dec_fmt)
ws2.write_number(row2, 10, round(wh, 2), td_dec_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
# Grand total menggunakan sumber data yang sama dengan Sheet 1 (stage_wh_map).
ws2.merge_range(row2, 1, row2, 7, 'GRAND TOTAL UMUR TIKET', section_fmt)
ws2.write(row2, 8, round(wh_total / 8.0, 2), td_dec_fmt)
ws2.write(row2, 9, wh_total, 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)
# Grand total using Excel formulas
ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt)
ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt)
ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', td_dec_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
ws3 = workbook.add_worksheet('Percakapan & Respon')
# SHEET 3: CONVERSATIONS & NOTES
ws3 = workbook.add_worksheet('Conversations & Notes')
ws3.set_column('A:A', 5)
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('E:E', 24)
ws3.set_column('F:F', 55)
ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt)
headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan']
ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt)
headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content']
for col_idx, h in enumerate(headers_ws3, start=1):
ws3.write(3, col_idx, h, th_fmt)
row3 = 4
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, 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, 4, conv.user_name or '-', td_fmt)
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
row3 += 1
# Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard
# HTML summary for wizard pop-up dialog
html_out = f"""
<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>
<p class="mb-1"><strong>Subjek:</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>
<h4 class="alert-heading fw-bold mb-2">📊 Ticket Bottleneck Analysis #{t.external_ticket_id}</h4>
<p class="mb-1"><strong>Subject:</strong> {t.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"/>
<div class="row text-center mb-3">
<div class="col-md-3">
<div class="bg-light p-2 rounded border">
<small class="text-muted d-block">TOTAL WAKTU</small>
<strong class="text-dark fs-5">{t.resolution_time_days} Hari</strong>
<small class="text-muted d-block">TOTAL DURATION</small>
<strong class="text-dark fs-5">{t.resolution_time_days} Days</strong>
</div>
</div>
<div class="col-md-3">
<div class="bg-light p-2 rounded border border-danger">
<small class="text-danger d-block fw-bold">WAITING ON US (IT)</small>
<strong class="text-danger fs-5">{t.total_agent_time_days} Hari</strong>
<small class="text-danger d-block fw-bold">AGENT RESPONSIBILITY</small>
<strong class="text-danger fs-5">{t.total_agent_time_days} Days</strong>
</div>
</div>
<div class="col-md-3">
<div class="bg-light p-2 rounded border border-warning">
<small class="text-warning d-block fw-bold">WAITING ON USER</small>
<strong class="text-warning fs-5">{t.total_customer_time_days} Hari</strong>
<small class="text-warning d-block fw-bold">REQUESTER RESPONSIBILITY</small>
<strong class="text-warning fs-5">{t.total_customer_time_days} Days</strong>
</div>
</div>
<div class="col-md-3">
<div class="bg-light p-2 rounded border border-primary">
<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>
"""
return html_out
# <div class="alert alert-danger mb-0">
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
# {primary_bn_text}
@@ -751,10 +832,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
def _build_ranking_sheets(
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,
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)."""
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('B:B', 8)
ws.set_column('C:C', 14)
@@ -773,20 +856,20 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws.set_column('P:P', 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(
'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
)
headers = [
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT',
'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent',
'Status',
'Total Hari Kalender', 'Total Jam Kerja (08:30-17:30)', 'Total Hari Kerja Efektif',
'Waktu IT (Hari Kalender)', 'Waktu IT (Jam Kerja)',
'Waktu User (Hari Kalender)', 'Waktu User (Jam Kerja)',
'Bottleneck Utama (Stage)', 'Durasi Bottleneck (Hari Kalender)', 'Durasi Bottleneck (Jam Kerja)'
'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays',
'Agent Time (Calendar Days)', 'Agent Time (Working Hours)',
'User Time (Calendar Days)', 'User Time (Working Hours)',
'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)'
]
for col_idx, h in enumerate(headers, start=1):
ws.write(4, col_idx, h, th_fmt)
@@ -805,7 +888,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
top_wh = 0.0
if top_stage:
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_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
@@ -815,16 +898,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
t_wh_cust = 0.0
for s in t.stage_log_ids:
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
elif s.responsibility == 'customer':
elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'):
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)
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, 2, f"#{t.external_ticket_id}", td_center_fmt)
@@ -847,7 +930,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
row += 1
# 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('B:B', 14)
ws2.set_column('C:C', 22)
@@ -862,13 +945,13 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
ws2.set_column('L:L', 32)
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 = [
'ID Tiket', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
'Tanggung Jawab', 'Pengguna',
'Hari Kalender', 'Jam Kalender',
'Jam Kerja (08:30-17:30)', 'Hari Kerja Efektif',
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
'Responsibility', 'Actor',
'Calendar Days', 'Calendar Hours',
'Working Hours (08:30-17:30)', 'Effective Workdays',
'Activity Details', 'Reply / Message Content'
]
for col_idx, h in enumerate(headers2, start=1):
ws2.write(3, col_idx, h, th_fmt)
@@ -877,17 +960,23 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
for t in sorted_tickets:
for log in t.stage_log_ids.sorted('start_date'):
if log.responsibility == 'internal':
resp_label = 'Internal (Waiting on Us)'
resp_label = 'Agent Active Work'
elif log.responsibility == 'customer':
resp_label = 'Customer (Waiting on User)'
elif log.responsibility == 'on_hold':
resp_label = 'On Hold (SLA is Paused)'
resp_label = 'Waiting on Requester'
elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'):
resp_label = 'Agent Hold & Discussion'
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 (Waiting Together - SLA is Paused)'
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:
resp_label = 'Waiting on Internal Discussion (SLA is Paused)'
resp_label = 'Waiting on Requester'
msg_body = self._get_stage_conversation_body(log, t)
raw_act_main = log.activity_content or ''
if 'Isi Pesan:' in raw_act_main:
@@ -900,7 +989,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
from datetime import timedelta as _td2
def _wib(dt):
if not dt:
return 'Aktif'
return 'Active'
try:
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
@@ -910,8 +999,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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, 4, log.stage_name or '-', td_fmt)
resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility == 'customer' else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt)))
# Waiting on User memakai normal_badge_fmt orange/kuning.
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, 7, calc_log_days, td_num_fmt)
@@ -925,15 +1013,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
html_out = f"""
<div class="alert alert-info border shadow-sm">
<h4 class="alert-heading fw-bold mb-2">📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat</h4>
<p class="mb-1">Total Tiket yang Dievaluasi: <strong>{len(sorted_tickets)} Tiket</strong></p>
<p class="mb-2">Rata-rata Waktu Penyelesaian: <strong>{avg_days:.1f} Hari Kalender</strong></p>
<h4 class="alert-heading fw-bold mb-2">📋 Multi-Ticket Bottleneck Ranking Report Generated</h4>
<p class="mb-1">Total Evaluated Tickets: <strong>{len(sorted_tickets)} Tickets</strong></p>
<p class="mb-2">Average Resolution Time: <strong>{avg_days:.1f} Calendar Days</strong></p>
<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">
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
</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>
"""
return html_out
return html_out