[ADD] iitial commit gmn fresh service

This commit is contained in:
Madanil
2026-10-19 08:07:51 +07:00
commit ebdc2ee304
38 changed files with 4111 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import gmn_freshservice_ticket_bottleneck_wizard_v01

View File

@@ -0,0 +1,883 @@
# -*- coding: utf-8 -*-
import io
import re
import base64
import logging
from datetime import datetime
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import xlsxwriter
from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days, compute_working_hours, format_working_duration
_logger = logging.getLogger(__name__)
class FreshserviceTicketBottleneckWizard(models.TransientModel):
_name = 'freshservice.ticket.bottleneck.wizard'
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
report_type = fields.Selection([
('single', 'Analisis Detail Bottleneck Tiket Tunggal (cth: #992)'),
('ranking', 'Ranking Bottleneck Multi-Tiket (Bahan Meeting)'),
], string='Tipe Laporan', default='single', required=True)
ticket_id = fields.Many2one(
'freshservice.ticket',
string='Pilih Tiket Odoo',
help='Pilih tiket yang sudah ada di database Odoo'
)
external_ticket_id = fields.Char(
string='Nomor ID Tiket Freshservice',
default='992',
help='Masukkan nomor ID Freshservice (misal: 992) untuk langsung mencari atau menarik data tiket tersebut.'
)
ticket_ids = fields.Many2many(
'freshservice.ticket',
'freshservice_wizard_ticket_rel',
'wizard_id',
'ticket_id',
string='Pilih Daftar Tiket Spesifik (Multi-Tiket)',
help='Kosongkan jika ingin mengambil tiket berdasarkan filter tanggal/status di bawah.'
)
date_from = fields.Date(string='Dari Tanggal Dibuat')
date_to = fields.Date(string='Sampai Tanggal Dibuat')
status_filter = fields.Selection([
('all', 'Semua Status'),
('open', 'Open'),
('pending', 'Pending User'),
('resolved', 'Resolved'),
('closed', 'Closed'),
], string='Filter Status', default='all')
auto_sync_detail = fields.Boolean(
string='Auto-Fetch Detail Freshservice',
default=True,
help='Jika dicentang, wizard akan otomatis mengambil rincian stage log dan percakapan terbaru dari Freshservice bila belum tersinkron.'
)
state = fields.Selection([
('choose', 'Pilih Parameter'),
('get', 'Laporan Siap Diunduh'),
], string='Status', default='choose')
excel_file = fields.Binary(string='File Excel Laporan', readonly=True)
file_name = fields.Char(string='Nama File', readonly=True)
bottleneck_summary_html = fields.Html(string='Ringkasan Analisis Bottleneck', readonly=True)
@api.onchange('ticket_id')
def _onchange_ticket_id(self):
if self.ticket_id and self.ticket_id.external_ticket_id:
self.external_ticket_id = self.ticket_id.external_ticket_id
def _strip_html(self, text):
"""Menghapus tag HTML agar teks di Excel bersih."""
if not text:
return ""
clean = re.sub(r'<[^>]+>', ' ', text)
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _ensure_ticket_details(self, ticket):
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
if self.auto_sync_detail:
try:
ticket.action_sync_from_freshservice()
except Exception as e:
_logger.warning("Gagal auto-sync detail untuk tiket #%s: %s", ticket.external_ticket_id, str(e))
def action_generate_excel(self):
"""Generate laporan analisis bottleneck dalam format Excel (.xlsx)."""
self.ensure_one()
tickets = self.env['freshservice.ticket']
if self.report_type == 'single':
# 1. Cari atau buat tiket berdasarkan ticket_id atau external_ticket_id
target_ticket = self.ticket_id
if not target_ticket and self.external_ticket_id:
clean_ext_id = self.external_ticket_id.strip()
target_ticket = self.env['freshservice.ticket'].search([('external_ticket_id', '=', clean_ext_id)], limit=1)
if not target_ticket:
# Buat record baru jika belum ada di Odoo, lalu sinkronkan
target_ticket = self.env['freshservice.ticket'].create({
'name': f'Tiket Freshservice #{clean_ext_id}',
'external_ticket_id': clean_ext_id,
})
try:
target_ticket.action_sync_from_freshservice()
except Exception as e:
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
if not target_ticket:
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
self._ensure_ticket_details(target_ticket)
tickets = target_ticket
report_name = f"Report_Tiket_{target_ticket.external_ticket_id}_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
else:
# Mode Multi-Tiket / Ranking
if self.ticket_ids:
tickets = self.ticket_ids
else:
domain = []
if self.date_from:
domain.append(('ticket_created_at', '>=', fields.Datetime.to_datetime(self.date_from)))
if self.date_to:
domain.append(('ticket_created_at', '<=', fields.Datetime.to_datetime(self.date_to).replace(hour=23, minute=59, second=59)))
if self.status_filter and self.status_filter != 'all':
domain.append(('status', '=', self.status_filter))
tickets = self.env['freshservice.ticket'].search(domain, order='resolution_time_days desc, id desc')
if not tickets:
raise UserError(_("Tidak ada tiket yang ditemukan dengan kriteria filter tersebut."))
for t in tickets[:50]: # Batasi auto-sync rincian untuk performa optimal
if self.auto_sync_detail:
try:
t.action_sync_from_freshservice()
except Exception:
pass
report_name = f"Ranking_Bottleneck_Tiket_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
# Generate file Excel menggunakan xlsxwriter
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True, 'remove_timezone': True})
# ==========================================
# FORMAT STYLING EXCEL
# ==========================================
title_fmt = workbook.add_format({
'bold': True, 'font_size': 15, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#1B365D',
'align': 'left', 'valign': 'vcenter'
})
subtitle_fmt = workbook.add_format({
'italic': True, 'font_size': 9, 'font_name': 'Calibri',
'font_color': '#5D6D7E', 'align': 'left'
})
section_fmt = workbook.add_format({
'bold': True, 'font_size': 11, 'font_name': 'Calibri',
'font_color': '#1B365D', 'bg_color': '#EAEDED',
'bottom': 1, 'top': 1, 'border_color': '#BDC3C7'
})
th_fmt = workbook.add_format({
'bold': True, 'font_size': 10, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#2E4053',
'align': 'center', 'valign': 'vcenter', 'border': 1, 'border_color': '#BDC3C7'
})
td_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'border': 1,
'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_wrap_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'border': 1,
'border_color': '#D5D8DC', 'valign': 'top', 'text_wrap': True
})
td_center_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_num_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'right',
'num_format': '#,##0', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_dec_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'right',
'num_format': '#,##0.00', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_pct_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'right',
'num_format': '0.0%', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_date_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'num_format': 'yyyy-mm-dd hh:mm', 'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
# Format Khusus Highlight Bottleneck
bn_badge_fmt = workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1,
'border_color': '#E6B0AA', 'align': 'center', 'valign': 'vcenter'
})
normal_badge_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'
})
kpi_box_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
})
kpi_box_val = workbook.add_format({
'font_size': 12, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FFFFFF', 'font_color': '#154360', 'border': 1, 'border_color': '#AED6F1',
'align': 'center', 'valign': 'vcenter'
})
# Format Badge Status Tiket
status_formats = {
'open': workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#FADBD8', 'font_color': '#C0392B', 'border': 1,
'border_color': '#E6B0AA', 'align': 'center', 'valign': 'vcenter'
}), # Merah
'pending': workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#FCF3CF', 'font_color': '#B7950B', 'border': 1,
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
}), # Kuning
'resolved': workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#D4EFDF', 'font_color': '#1E8449', 'border': 1,
'border_color': '#A9DFBF', 'align': 'center', 'valign': 'vcenter'
}), # Hijau
'closed': workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#E5E7E9', 'font_color': '#5D6D7E', 'border': 1,
'border_color': '#BDC3C7', 'align': 'center', 'valign': 'vcenter'
}), # Abu-abu
}
# Format abu-abu untuk status SLA Paused (On Hold / Third Party / Internal Discussion)
sla_paused_fmt = workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#E5E7E9', 'font_color': '#5D6D7E', 'border': 1,
'border_color': '#BDC3C7', 'align': 'center', 'valign': 'vcenter'
})
summary_html = ""
if self.report_type == 'single':
t = tickets[0]
summary_html = self._build_single_ticket_sheets(
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,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt
)
else:
summary_html = self._build_ranking_sheets(
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
)
workbook.close()
output.seek(0)
excel_bytes = output.read()
output.close()
excel_b64 = base64.b64encode(excel_bytes)
self.write({
'excel_file': excel_b64,
'file_name': report_name,
})
# Return action download langsung agar browser segera mendownload file Excel
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/?model={self._name}&id={self.id}&field=excel_file&filename={report_name}&download=true',
'target': 'self',
}
def _get_stage_conversation_body(self, log, ticket):
"""Mendapatkan isi pesan atau balasan untuk stage log tertentu.
Mencari dari log.activity_content atau fallback mencocokkan waktu dengan ticket.conversation_ids.
"""
raw_act = log.activity_content or ''
# Jika sudah ada "Isi Pesan:" yang disematkan saat sync
if 'Isi Pesan:' in raw_act:
parts = raw_act.split('Isi Pesan:')
if len(parts) > 1:
return parts[1].strip().strip('"').strip("'")
# Fallback: cari di conversation_ids dengan timestamp yang sama atau berdekatan (< 5 detik)
if log.start_date and ticket.conversation_ids:
for conv in ticket.conversation_ids:
if conv.created_at:
diff = abs((conv.created_at - log.start_date).total_seconds())
if diff <= 5:
body = conv.body_text or self._strip_html(conv.body) or ''
if body.strip():
return body.strip()
# Jika aksi adalah reply/note tapi tidak ada pesan terpisah, cek apakah di activity_content ada teks aksi
return ''
def _build_single_ticket_sheets(
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,
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None
):
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
ws1.set_column('A:A', 5)
ws1.set_column('B:B', 26)
ws1.set_column('C:C', 30)
ws1.set_column('D:D', 26)
ws1.set_column('E:E', 26)
ws1.set_column('F:F', 22)
ws1.set_column('G:G', 16)
ws1.set_column('H:H', 16)
ws1.set_column('I:I', 14)
# Header Title
ws1.merge_range('B2:H2', f'LAPORAN ANALISIS TIKET #{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}',
subtitle_fmt
)
# Info Header Tiket
ws1.merge_range('B5:H5', 'Informasi Tiket', 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)
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('B7', 'Judul Tiket', td_fmt)
ws1.merge_range('B7:E7', t.name or '-', td_fmt)
ws1.write('B8', 'Pemohon (User)', td_fmt)
ws1.write('C8', f"{t.requester_name or '-'} ({t.requester_email or '-'})", td_fmt)
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('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('C10', str(t.ticket_created_at or '-'), td_fmt)
ws1.write('D10', 'Waktu Selesai', td_fmt)
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
# KPI BOXES
ws1.write('B12', 'METRIK DURASI & TANGGUNG JAWAB', section_fmt)
ws1.merge_range('B12:H12', 'METRIK DURASI & TANGGUNG JAWAB', section_fmt)
# --- Baris 13-14: Metrik Hari Kalender (Lama) ---
# kpi_cal_label = workbook.add_format({
# 'font_size': 9, 'font_name': 'Calibri', 'bold': True,
# 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
# })
# ws1.write('B13', '📅 First Response (Hari Kalender)', kpi_cal_label)
# ws1.write('C13', '📅 Total Waktu (Hari Kalender)', kpi_cal_label)
# ws1.write('D13', '📅 Waiting on Us / IT (Hari Kalender)', kpi_cal_label)
# ws1.write('E13', '📅 Waiting on User (Hari Kalender)', kpi_cal_label)
# ws1.write('F13', 'Status Tanggung Jawab', kpi_cal_label)
# Recalculate duration on-the-fly agar data lama langsung menggunakan logika hari kerja tanpa perlu re-sync
calc_fr_days = compute_calendar_days(self.env, t.ticket_created_at, t.first_responded_at) if (t.ticket_created_at and t.first_responded_at) else 0
calc_res_days = compute_calendar_days(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0
stage_days_map = {}
calc_agent_days = 0
calc_cust_days = 0
for s in t.stage_log_ids:
s_days = compute_calendar_days(self.env, s.start_date, s.end_date)
stage_days_map[s.id] = s_days
if s.responsibility == 'internal':
calc_agent_days += s_days
elif s.responsibility == 'customer':
calc_cust_days += s_days
# ws1.write('B14', f"{calc_fr_days} Hari Kalender ({t.first_responder_name or '-'})", kpi_box_val)
# ws1.write('C14', f"{calc_res_days} Hari Kalender", kpi_box_val)
# ws1.write('D14', f"{calc_agent_days} Hari Kalender", kpi_box_val)
# ws1.write('E14', f"{calc_cust_days} Hari Kalender", kpi_box_val)
# ws1.write('F14', dict(t._fields['current_responsibility'].selection).get(t.current_responsibility, '-'), kpi_box_val)
# --- Baris 15-16: Metrik Jam Kerja Operasional (Baru, UTC+7) ---
kpi_wh_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#A9DFBF'
})
kpi_wh_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
'align': 'center', 'valign': 'vcenter'
})
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_wh_label)
ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label)
ws1.write('F13', 'Total SLA Paused', kpi_wh_label)
ws1.write('G13', 'Resolved / Idle', 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)
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
stage_wh_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)
stage_wh_map[s.id] = s_wh
if s.responsibility == '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':
wh_cust += s_wh
elif s.responsibility == 'scheduled_meeting':
wh_meeting += s_wh
elif s.responsibility == 'third_party':
wh_vendor += s_wh
elif s.responsibility == 'external_activity':
wh_other_paused += s_wh
elif s.responsibility == '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_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_box_val)
ws1.write('E14', format_working_duration(wh_cust), kpi_box_val)
ws1.write('F14', format_working_duration(wh_paused), kpi_box_val)
ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_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)
# 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)
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 (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust),
('Tunggu Bersama (Scheduled Meeting)', '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),
]
for recap_row, (label, description, hours) in enumerate(recap_rows, start=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)))
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)
# TABEL RANKING BOTTLENECK PER STAGE
# Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga
# ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25.
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):
ws1.write(29, 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 = 30
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):
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_fmt = bn_badge_fmt
elif stage.responsibility == 'customer':
resp_label = 'Waiting on User (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 = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
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
else:
resp_label = 'Resolved (Ticket Closed/Idle)'
resp_fmt = sla_paused_fmt or td_center_fmt
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, 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)
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)
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.merge_range('B2:L2', f'KRONOLOGI LENGKAP AUDIT TRAIL TIKET #{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'
]
for col_idx, h in enumerate(headers_ws2, start=1):
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
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)
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)
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, 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)
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)
# SHEET 3: RIWAYAT PERCAKAPAN
ws3 = workbook.add_worksheet('Percakapan & Respon')
ws3.set_column('A:A', 5)
ws3.set_column('B:B', 8)
ws3.set_column('C:C', 18)
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']
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'
ws3.write(row3, 1, idx3, td_center_fmt)
ws3.write(row3, 2, str(conv.created_at or '-'), 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_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>
<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>
</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>
</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>
</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>
</div>
</div>
</div>
</div>
"""
# <div class="alert alert-danger mb-0">
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
# {primary_bn_text}
# </div>
return html_out
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
):
"""Membuat sheet ranking bottleneck untuk multi-tiket (bahan meeting evaluasi tim)."""
ws = workbook.add_worksheet('Ranking Bottleneck Tiket')
ws.set_column('A:A', 5)
ws.set_column('B:B', 8)
ws.set_column('C:C', 14)
ws.set_column('D:D', 30)
ws.set_column('E:E', 20)
ws.set_column('F:F', 18)
ws.set_column('G:G', 18)
ws.set_column('H:H', 12)
ws.set_column('I:I', 16)
ws.set_column('J:J', 16)
ws.set_column('K:K', 16)
ws.set_column('L:L', 16)
ws.set_column('M:M', 16)
ws.set_column('N:N', 26)
ws.set_column('O:O', 22)
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.write(
'B3',
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
subtitle_fmt
)
headers = [
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT',
'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)'
]
for col_idx, h in enumerate(headers, start=1):
ws.write(4, col_idx, h, th_fmt)
# Sort tickets: urutkan dari durasi terlama (resolution_time_days desc atau total_agent_time_days desc)
sorted_tickets = tickets.sorted(key=lambda t: (t.resolution_time_days, t.total_agent_time_days), reverse=True)
row = 5
slowest_tickets = []
for rank_idx, t in enumerate(sorted_tickets, start=1):
# Cari stage bottleneck terbesar di tiket ini
top_stage = t.stage_log_ids.sorted(key=lambda s: (s.duration_days, s.duration_hours), reverse=True)
top_stage_name = '-'
top_resp = '-'
top_days = 0
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_days = top_stage[0].duration_days
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
# Hitung jam kerja per tiket
t_wh_total = compute_working_hours(self.env, t.ticket_created_at, t.resolved_at) if (t.ticket_created_at and t.resolved_at) else 0.0
t_wh_agent = 0.0
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':
t_wh_agent += s_wh
elif s.responsibility == 'customer':
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)")
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, 3, t.name or '-', td_fmt)
ws.write(row, 4, t.requester_name or '-', td_fmt)
ws.write(row, 5, t.department_name or '-', td_fmt)
ws.write(row, 6, t.responder_name or '-', td_fmt)
row_st_fmt = status_formats.get(t.status, td_center_fmt)
ws.write(row, 7, dict(t._fields['status'].selection).get(t.status, t.status or '-'), row_st_fmt)
ws.write(row, 8, t.resolution_time_days, td_num_fmt)
ws.write(row, 9, t_wh_total, td_dec_fmt)
ws.write(row, 10, t_wd_eff, td_dec_fmt)
ws.write(row, 11, t.total_agent_time_days, td_num_fmt)
ws.write(row, 12, t_wh_agent, td_dec_fmt)
ws.write(row, 13, t.total_customer_time_days, td_num_fmt)
ws.write(row, 14, t_wh_cust, td_dec_fmt)
ws.write(row, 15, top_stage_name, td_fmt)
ws.write(row, 16, top_days, td_num_fmt)
ws.write(row, 17, top_wh, td_dec_fmt)
row += 1
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET
ws2 = workbook.add_worksheet('Detail Stage Log Semua Tiket')
ws2.set_column('A:A', 5)
ws2.set_column('B:B', 14)
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', 20)
ws2.set_column('H:H', 14)
ws2.set_column('I:I', 14)
ws2.set_column('J:J', 18)
ws2.set_column('K:K', 18)
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)
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'
]
for col_idx, h in enumerate(headers2, start=1):
ws2.write(3, col_idx, h, th_fmt)
row2 = 4
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)'
elif log.responsibility == 'customer':
resp_label = 'Customer (Waiting on User)'
elif log.responsibility == 'on_hold':
resp_label = 'On Hold (SLA is Paused)'
elif log.responsibility == 'third_party':
resp_label = 'Waiting on Third Party (SLA is Paused)'
else:
resp_label = 'Waiting on Internal Discussion (SLA is Paused)'
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_log_days = compute_calendar_days(self.env, log.start_date, log.end_date)
log_wh = compute_working_hours(self.env, log.start_date, log.end_date)
log_wd_eff = round(log_wh / 8.0, 2)
from datetime import timedelta as _td2
def _wib(dt):
if not dt:
return 'Aktif'
try:
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
except Exception:
return str(dt)
ws2.write(row2, 1, f"#{t.external_ticket_id}", 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, 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, 6, log.actor_name or '-', td_fmt)
ws2.write(row2, 7, calc_log_days, td_num_fmt)
ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
ws2.write(row2, 9, log_wh, td_dec_fmt)
ws2.write(row2, 10, log_wd_eff, td_dec_fmt)
ws2.write(row2, 11, self._strip_html(raw_act_main) or '-', td_fmt)
ws2.write(row2, 12, msg_body or '-', td_wrap_fmt if msg_body else td_center_fmt)
row2 += 1
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>
<hr class="my-2"/>
<p class="mb-1 fw-bold text-danger">⚠️ Top 5 Tiket dengan Durasi Terlama:</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>
</div>
"""
return html_out

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- FORM VIEW WIZARD BOTTLENECK -->
<record id="view_freshservice_ticket_bottleneck_wizard_form" model="ir.ui.view">
<field name="name">freshservice.ticket.bottleneck.wizard.form</field>
<field name="model">freshservice.ticket.bottleneck.wizard</field>
<field name="arch" type="xml">
<form string="Laporan Analisis Bottleneck Tiket (Excel)">
<field name="state" invisible="1"/>
<!-- STATE 1: PILIH PARAMETER -->
<div invisible="state != 'choose'">
<div class="alert alert-primary mb-3 d-flex align-items-center gap-2" role="status" invisible="True">
<i class="fa fa-info-circle fa-2x" title="Informasi"/>
<div>
<strong>Ekspor Laporan Analisis Bottleneck ke Excel (.xlsx)</strong><br/>
Laporan ini dirancang khusus sebagai <em>bahan meeting</em> untuk mengidentifikasi tahapan, penanggung jawab, dan faktor yang menyebabkan tiket tertahan lama (Waiting on Us vs Waiting on User).
</div>
</div>
<group invisible="True">
<group string="Pilihan Laporan">
<field name="report_type" widget="radio"/>
</group>
</group>
<!-- MODE SINGLE TICKET -->
<!-- <group string="Parameter Tiket Tunggal" invisible="report_type != 'single'"> -->
<group>
<group>
<field name="external_ticket_id" placeholder="Contoh: 992"/>
<field name="ticket_id" options="{'no_create': True}" placeholder="Atau cari judul tiket di Odoo..."/>
</group>
<group invisible="True">
<field name="auto_sync_detail"/>
</group>
</group>
<!-- MODE RANKING MULTI-TIKET -->
<group string="Filter Multi-Tiket (Ranking)" invisible="report_type != 'ranking'">
<group>
<field name="date_from"/>
<field name="date_to"/>
</group>
<group>
<field name="status_filter"/>
<field name="auto_sync_detail"/>
</group>
<group colspan="2">
<field name="ticket_ids" widget="many2many_tags" placeholder="Kosongkan untuk menyertakan seluruh tiket sesuai filter di atas"/>
</group>
</group>
</div>
<!-- FOOTER BUTTONS -->
<footer>
<div invisible="state != 'choose'" class="d-flex flex-row gap-2 justify-content-end">
<button name="action_generate_excel"
string="Export"
type="object"
class="btn-primary"
icon="fa-file-excel-o"/>
<button string="Batal"
class="btn-secondary"
special="cancel"/>
</div>
</footer>
</form>
</field>
</record>
<!-- ACTION WINDOW WIZARD (POP-UP MODAL) -->
<record id="action_freshservice_ticket_bottleneck_wizard" model="ir.actions.act_window">
<field name="name">Laporan Analisis Bottleneck Excel</field>
<field name="res_model">freshservice.ticket.bottleneck.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_freshservice_ticket"/>
<field name="binding_view_types">form,list</field>
</record>
</odoo>