Compare commits

...

2 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
date_from = fields.Date(string='Dari Tanggal Dibuat')
date_to = fields.Date(string='Sampai Tanggal Dibuat')
as_of = fields.Datetime(
string='Cut-off As-Of (UTC)',
help='Jika diisi, semua durasi stage log dipotong sampai timestamp ini. '
'Timestamp laporan ditampilkan dalam WIB (UTC+7).',
)
status_filter = fields.Selection([
('all', 'Semua Status'),
@@ -80,6 +85,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _effective_responsibility(self, log):
"""Return the reviewed classification when present, otherwise the automatic one."""
return log.override_responsibility or log.responsibility
def _clipped_stage_interval(self, log, report_as_of):
"""Return the reportable (start, end) interval, clipped at the report cut-off."""
start = log.start_date
if not start:
return False, False
if report_as_of and start >= report_as_of:
return False, False
end = log.end_date or report_as_of
if report_as_of and (not end or end > report_as_of):
end = report_as_of
if not end or end <= start:
return False, False
return start, end
def _ensure_ticket_details(self, ticket):
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
if self.auto_sync_detail:
@@ -207,6 +230,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
})
scheduled_meeting_fmt = workbook.add_format({
'bold': True, 'font_size': 9, 'font_name': 'Calibri',
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1,
'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter'
})
kpi_box_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
@@ -255,13 +283,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
summary_html = self._build_single_ticket_sheets(
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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
)
workbook.close()
@@ -309,7 +339,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
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
bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt,
scheduled_meeting_fmt
):
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
@@ -324,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}',
@@ -332,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)
@@ -348,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
@@ -404,292 +435,394 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
'align': 'center', 'valign': 'vcenter'
})
# Warna KPI mengikuti responsibility: internal merah, customer orange,
# scheduled meeting biru, dan third party abu-abu.
kpi_internal_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA'
})
kpi_customer_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F'
})
kpi_meeting_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9'
})
kpi_third_party_label = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7'
})
kpi_internal_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA',
'align': 'center', 'valign': 'vcenter'
})
kpi_customer_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F',
'align': 'center', 'valign': 'vcenter'
})
kpi_meeting_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9',
'align': 'center', 'valign': 'vcenter'
})
kpi_third_party_val = workbook.add_format({
'font_size': 11, 'font_name': 'Calibri', 'bold': True,
'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7',
'align': 'center', 'valign': 'vcenter'
})
ws1.write('B13', 'First Response', kpi_wh_label)
ws1.write('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('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 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_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)
ws1.write('D14', format_working_duration(wh_agent), kpi_internal_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', '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 (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),
('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),
]
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)))
# 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):
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
# 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']
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(29, col_idx, h, th_fmt)
ws1.write(36, col_idx, h, th_fmt)
sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True)
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
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)'
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 = '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)'
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_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)
ws1.write(row, 1, f"#{rank_idx}", bn_badge_fmt if is_primary else td_center_fmt)
ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt)
ws1.write(row, 2, stage.stage_name or '-', td_fmt)
ws1.write(row, 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 (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)'
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)
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)
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}
@@ -699,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)
@@ -721,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)
@@ -753,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)
@@ -763,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)
@@ -795,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)
@@ -810,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)
@@ -825,15 +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 (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:
@@ -846,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:
@@ -856,7 +999,8 @@ 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)
ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt)))
resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'it_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility in ('customer', 'user_after_auto_resolve', 'needs_verification') else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt)))
ws2.write(row2, 5, resp_label, resp_fmt)
ws2.write(row2, 6, log.actor_name or '-', td_fmt)
ws2.write(row2, 7, calc_log_days, td_num_fmt)
ws2.write(row2, 8, log.duration_hours, td_dec_fmt)
@@ -869,15 +1013,16 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
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