[UPD] update logic tiket
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user