[ADD] iitial commit gmn fresh service
This commit is contained in:
2
gmn_freshservice/wizard/__init__.py
Normal file
2
gmn_freshservice/wizard/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
||||
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user