Compare commits
2 Commits
a44219a8f2
...
62e545276f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e545276f | ||
|
|
ef5ba7f159 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,7 @@ class FreshserviceTicketConversation(models.Model):
|
|||||||
|
|
||||||
sender_type = fields.Selection([
|
sender_type = fields.Selection([
|
||||||
('agent', 'IT Support (Agent)'),
|
('agent', 'IT Support (Agent)'),
|
||||||
('customer', 'User (Customer)'),
|
('customer', 'User (Requester)'),
|
||||||
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
|
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
|
||||||
|
|
||||||
body_text = fields.Text(string='Isi Pesan')
|
body_text = fields.Text(string='Isi Pesan')
|
||||||
|
|||||||
@@ -255,15 +255,36 @@ class FreshserviceTicketStageLog(models.Model):
|
|||||||
stage_name = fields.Char(string='Status / Stage', required=True)
|
stage_name = fields.Char(string='Status / Stage', required=True)
|
||||||
|
|
||||||
responsibility = fields.Selection([
|
responsibility = fields.Selection([
|
||||||
('internal', 'Internal (Waiting on Us)'),
|
('internal', 'Agent Active Work'),
|
||||||
('customer', 'Customer (Waiting on User)'),
|
('it_hold', 'Agent Hold & Discussion'),
|
||||||
('on_hold', 'On Hold (SLA is Paused)'),
|
('customer', 'Waiting on Requester'),
|
||||||
('third_party', 'Waiting on Third Party (SLA is Paused)'),
|
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
|
||||||
('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'),
|
('needs_verification', 'Waiting on User Verification'),
|
||||||
('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'),
|
('scheduled_meeting', 'Scheduled Meeting (Shared)'),
|
||||||
('external_activity', 'External Activity (SLA is Paused)'),
|
('third_party', 'Waiting on Third Party'),
|
||||||
('resolved_idle', 'Resolved (Ticket Closed/Idle)'),
|
('resolved_idle', 'Resolved / Idle'),
|
||||||
], string='Tanggung Jawab', required=True, default='internal', index=True)
|
# 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_name = fields.Char(string='Pelaku / Actor')
|
||||||
actor_type = fields.Selection([
|
actor_type = fields.Selection([
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
requester_name = fields.Char(string='Pemohon (User)', tracking=True)
|
requester_name = fields.Char(string='Pemohon (User)', tracking=True)
|
||||||
requester_email = fields.Char(string='Email Pemohon')
|
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')
|
department_name = fields.Char(string='Departemen')
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -62,9 +62,13 @@ class FreshserviceTicket(models.Model):
|
|||||||
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
|
ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True)
|
||||||
due_by = fields.Datetime(string='Batas Waktu (Due Date)')
|
due_by = fields.Datetime(string='Batas Waktu (Due Date)')
|
||||||
first_responded_at = fields.Datetime(string='Waktu First Response')
|
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)')
|
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(
|
is_detail_synced = fields.Boolean(
|
||||||
string='Detail Lengkap Tersinkron',
|
string='Detail Lengkap Tersinkron',
|
||||||
default=False,
|
default=False,
|
||||||
@@ -112,24 +116,58 @@ class FreshserviceTicket(models.Model):
|
|||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
total_agent_time_display = fields.Char(
|
total_agent_time_display = fields.Char(
|
||||||
string='Agen (Waiting on Us)',
|
string='Agent Responsibility',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
|
|
||||||
total_customer_time_days = fields.Integer(
|
total_customer_time_days = fields.Integer(
|
||||||
string='Total Waktu di User (Hari)',
|
string='Requester Time (Days)',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
total_customer_time_hours = fields.Float(
|
total_customer_time_hours = fields.Float(
|
||||||
string='Total Waktu di User (Jam)',
|
string='Requester Time (Hours)',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True,
|
store=True,
|
||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
total_customer_time_display = fields.Char(
|
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',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
@@ -146,16 +184,16 @@ class FreshserviceTicket(models.Model):
|
|||||||
digits=(12, 2)
|
digits=(12, 2)
|
||||||
)
|
)
|
||||||
resolution_time_display = fields.Char(
|
resolution_time_display = fields.Char(
|
||||||
string='Total Waktu Selesai',
|
string='Total Resolution Time',
|
||||||
compute='_compute_time_metrics',
|
compute='_compute_time_metrics',
|
||||||
store=True
|
store=True
|
||||||
)
|
)
|
||||||
|
|
||||||
current_responsibility = fields.Selection([
|
current_responsibility = fields.Selection([
|
||||||
('internal', 'Waiting on Us (Internal)'),
|
('internal', 'Agent Active Work'),
|
||||||
('customer', 'Waiting on User (Customer)'),
|
('customer', 'Waiting on Requester'),
|
||||||
('closed', 'Tiket Selesai'),
|
('closed', 'Completed'),
|
||||||
], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True)
|
], string='Current Responsibility', compute='_compute_time_metrics', store=True)
|
||||||
|
|
||||||
# Relasi
|
# Relasi
|
||||||
stage_log_ids = fields.One2many(
|
stage_log_ids = fields.One2many(
|
||||||
@@ -171,7 +209,7 @@ class FreshserviceTicket(models.Model):
|
|||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced',
|
'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):
|
def _compute_time_metrics(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
@@ -190,14 +228,24 @@ class FreshserviceTicket(models.Model):
|
|||||||
rec.first_response_time_display = "Belum Ada Respon"
|
rec.first_response_time_display = "Belum Ada Respon"
|
||||||
|
|
||||||
# 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender.
|
# 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')
|
# Mengikuti Aturan Fair Responsibility:
|
||||||
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer')
|
# - 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)
|
agent_days = round(agent_hours / 8.0, 2)
|
||||||
cust_days = round(cust_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.
|
# 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)
|
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_hours = round(agent_hours, 2)
|
||||||
rec.total_agent_time_days = round(agent_days)
|
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_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.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
|
# 3. Hitung Resolution Time
|
||||||
if rec.ticket_created_at and rec.resolved_at:
|
if rec.ticket_created_at and rec.resolved_at:
|
||||||
delta_res = rec.resolved_at - rec.ticket_created_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):
|
def action_sync_from_freshservice(self):
|
||||||
"""
|
"""
|
||||||
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
|
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
|
||||||
@@ -289,9 +367,9 @@ class FreshserviceTicket(models.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
get_param = self.env['ir.config_parameter'].sudo().get_param
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
base_url = get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id')
|
base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
|
||||||
api_key = get_param('freshservice_ticket_audit.freshservice_api_key', '')
|
api_key = get_param('gmn_freshservice.freshservice_api_key', '')
|
||||||
use_mock = get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
|
use_mock = False
|
||||||
|
|
||||||
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=use_mock)
|
||||||
payload = client.fetch_ticket_full_data(self.external_ticket_id)
|
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)
|
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
|
||||||
|
|
||||||
log_vals_list = []
|
log_vals_list = []
|
||||||
|
# Hanya perubahan status eksplisit yang mengubah state berikutnya.
|
||||||
|
effective_status = 'Open'
|
||||||
current_state = 'internal'
|
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_wait_until = False
|
||||||
meeting_start_dt = False
|
meeting_start_dt = False
|
||||||
meeting_waiting_for_followup = False
|
meeting_waiting_for_followup = False
|
||||||
@@ -504,59 +588,128 @@ class FreshserviceTicket(models.Model):
|
|||||||
else:
|
else:
|
||||||
stage_name = 'Aktivitas'
|
stage_name = 'Aktivitas'
|
||||||
|
|
||||||
# 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine.
|
# 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification
|
||||||
# Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state.
|
# ==============================================================================
|
||||||
|
# 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()
|
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:
|
# Update pesan terakhir jika ada balasan/catatan publik
|
||||||
stage_name = 'Create Timesheet'
|
if matched_msg:
|
||||||
responsibility = 'external_activity'
|
last_public_message = matched_msg
|
||||||
else:
|
last_message_actor_type = actor_type
|
||||||
status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower()
|
elif 'replied' in content_main.lower() or 'reply' in content_main.lower():
|
||||||
explicit_status = 'set status as' in status_text
|
last_public_message = content_main
|
||||||
is_requester_reopen_workflow = (
|
last_message_actor_type = actor_type
|
||||||
actor_type == 'System'
|
|
||||||
and 'reopen tickets when the requester responds' in status_text
|
# Rekam First Response (Aturan E)
|
||||||
)
|
if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()):
|
||||||
requester_email = (self.requester_email or '').strip().lower()
|
is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting'))
|
||||||
is_agent_reply_to_requester = (
|
if not first_ack_dt:
|
||||||
actor_type.strip().lower() == 'agent'
|
first_ack_dt = start_dt_obj
|
||||||
and bool(re.search(r'replied\s+to', status_text, re.I))
|
if not is_auto_ack and not first_meaningful_dt:
|
||||||
)
|
first_meaningful_dt = start_dt_obj
|
||||||
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))
|
classification_basis = "Status warisan: %s" % effective_status
|
||||||
confirmation_to_user = bool(re.search(r'(mohon|please|harap|kindly).{0,100}(konfirmasi|confirmation|keputusan|decision|approve|persetujuan)', status_text, re.I))
|
needs_verification_flag = False
|
||||||
if is_agent_assignment:
|
delay_cause = 'none'
|
||||||
# Penugasan/perubahan agent mengalihkan tanggung jawab kembali ke internal (Waiting on Us).
|
|
||||||
current_state = 'internal'
|
# Update effective_status hanya jika ada perubahan eksplisit
|
||||||
elif vendor_external or (explicit_status and 'waiting on third party' in status_text):
|
if vendor_external or (explicit_status and 'waiting on third party' in status_text):
|
||||||
# Vendor Escalation=External adalah trigger resmi meski bukan field Status.
|
effective_status = 'Waiting on Third Party'
|
||||||
current_state = 'third_party'
|
current_state = 'third_party'
|
||||||
elif explicit_status and 'waiting on internal discussion' in status_text:
|
classification_basis = 'Status Freshservice: Waiting on Third Party'
|
||||||
current_state = 'internal_discussion'
|
elif explicit_status and 'waiting on internal discussion' in status_text:
|
||||||
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 Internal Discussion'
|
||||||
current_state = 'customer'
|
current_state = 'it_hold'
|
||||||
elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')):
|
classification_basis = 'Status Freshservice: Waiting on Internal Discussion'
|
||||||
current_state = 'on_hold'
|
elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')):
|
||||||
elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')):
|
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'
|
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')):
|
classification_basis = 'Status Freshservice: Resolved'
|
||||||
current_state = 'internal'
|
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')):
|
||||||
elif is_agent_reply_to_requester:
|
effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open'
|
||||||
# Agent sudah membalas requester; giliran berikutnya
|
current_state = 'internal'
|
||||||
# menunggu respons user dan dibebankan ke end user (customer).
|
classification_basis = 'Status Freshservice: %s' % effective_status
|
||||||
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'
|
|
||||||
|
|
||||||
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_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:
|
if is_meeting_invitation:
|
||||||
parsed_meeting_dt = False
|
parsed_meeting_dt = False
|
||||||
time_match = meeting_time_pattern.search(full_activity_content or '')
|
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_start_dt = parsed_meeting_dt
|
||||||
meeting_waiting_for_followup = True
|
meeting_waiting_for_followup = True
|
||||||
meeting_wait_until = False
|
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:
|
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
|
||||||
meeting_wait_until = False
|
meeting_wait_until = False
|
||||||
@@ -581,22 +738,27 @@ class FreshserviceTicket(models.Model):
|
|||||||
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
|
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
|
||||||
)
|
)
|
||||||
if is_meeting_invitation:
|
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
|
meeting_wait_until = False
|
||||||
elif meeting_waiting_for_followup and start_dt_obj:
|
elif is_meeting_cancelled:
|
||||||
# No parsable Time: waits together until Agent follow-up. With Time:,
|
# Tanggung jawab kembali ke status operasional saat ini (current_state)
|
||||||
# the period from meeting start until that same follow-up is internal.
|
responsibility = current_state
|
||||||
if meeting_start_dt and start_dt_obj >= meeting_start_dt:
|
elif (
|
||||||
responsibility = 'internal'
|
meeting_waiting_for_followup
|
||||||
if is_agent_followup:
|
and not is_meeting_invitation
|
||||||
meeting_waiting_for_followup = False
|
and 'waiting on internal discussion' in status_text.lower()
|
||||||
meeting_start_dt = False
|
):
|
||||||
meeting_wait_until = start_dt_obj
|
# Setelah link meeting dikirim, status ini masih merupakan
|
||||||
else:
|
# fase Scheduled Meeting dan tidak membatalkan state meeting.
|
||||||
responsibility = 'scheduled_meeting'
|
responsibility = 'scheduled_meeting'
|
||||||
if not meeting_start_dt and is_agent_followup:
|
elif meeting_waiting_for_followup:
|
||||||
meeting_waiting_for_followup = False
|
# Keep the complete interval shared while the ticket remains in
|
||||||
meeting_wait_until = start_dt_obj
|
# 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
|
# 5. Set First Responder jika belum terdeteksi
|
||||||
if not self.first_responder_name and actor_type == 'Agent':
|
if not self.first_responder_name and actor_type == 'Agent':
|
||||||
@@ -606,35 +768,34 @@ class FreshserviceTicket(models.Model):
|
|||||||
log_values = {
|
log_values = {
|
||||||
'stage_name': stage_name,
|
'stage_name': stage_name,
|
||||||
'responsibility': responsibility,
|
'responsibility': responsibility,
|
||||||
|
'effective_status': effective_status,
|
||||||
|
'classification_basis': classification_basis,
|
||||||
|
'needs_verification': needs_verification_flag,
|
||||||
|
'delay_cause': delay_cause,
|
||||||
'actor_name': actor_name,
|
'actor_name': actor_name,
|
||||||
'actor_type': actor_type,
|
'actor_type': actor_type,
|
||||||
'activity_content': full_activity_content or stage_name,
|
'activity_content': full_activity_content or stage_name,
|
||||||
'start_date': start_dt,
|
'start_date': start_dt,
|
||||||
'end_date': end_dt,
|
'end_date': end_dt,
|
||||||
}
|
}
|
||||||
# Split every affected interval at the scheduled start precisely:
|
# Keep one continuous shared-time interval while the effective status is
|
||||||
# invitation → meeting = shared SLA-paused wait; meeting → next activity = internal follow-up.
|
# Internal Discussion or Scheduled Meeting. The interval changes only
|
||||||
if interval_meeting_start and end_dt_obj and start_dt_obj < interval_meeting_start < end_dt_obj:
|
# when the next activity/status explicitly changes responsibility.
|
||||||
waiting_values = dict(
|
log_vals_list.append((0, 0, log_values))
|
||||||
log_values,
|
|
||||||
responsibility='scheduled_meeting',
|
ticket_write_vals = {}
|
||||||
end_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'),
|
if first_ack_dt:
|
||||||
)
|
ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
followup_values = dict(
|
if first_meaningful_dt:
|
||||||
log_values,
|
ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
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))
|
|
||||||
|
|
||||||
if log_vals_list:
|
if log_vals_list:
|
||||||
# Baru ganti snapshot setelah daftar audit trail pengganti siap.
|
# Baru ganti snapshot setelah daftar audit trail pengganti siap.
|
||||||
self.stage_log_ids.unlink()
|
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.
|
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
|
||||||
conv_vals_list = []
|
conv_vals_list = []
|
||||||
@@ -657,7 +818,7 @@ class FreshserviceTicket(models.Model):
|
|||||||
'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
|
'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 {
|
return {
|
||||||
'type': 'ir.actions.client',
|
'type': 'ir.actions.client',
|
||||||
'tag': 'display_notification',
|
'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
|
# 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()
|
param_obj = self.env['ir.config_parameter'].sudo()
|
||||||
if not limit:
|
if not limit:
|
||||||
try:
|
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):
|
except (ValueError, TypeError):
|
||||||
limit = 200
|
limit = 200
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
limit = 200
|
limit = 200
|
||||||
|
|
||||||
now_ts = fields.Datetime.now().timestamp()
|
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:
|
if last_sync:
|
||||||
try:
|
try:
|
||||||
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
|
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
|
||||||
@@ -726,14 +868,14 @@ class FreshserviceTicket(models.Model):
|
|||||||
return 0
|
return 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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')
|
base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id')
|
||||||
api_key = param_obj.get_param('freshservice_ticket_audit.freshservice_api_key', '')
|
api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '')
|
||||||
use_mock = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True)
|
use_mock = False
|
||||||
|
|
||||||
try:
|
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)
|
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
|
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
|
||||||
@@ -884,10 +1026,10 @@ class FreshserviceTicket(models.Model):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning("Auto fetch saat buka menu tiket: %s", 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()
|
param_obj = self.env['ir.config_parameter'].sudo()
|
||||||
try:
|
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:
|
if sync_limit > 0:
|
||||||
action['limit'] = sync_limit
|
action['limit'] = sync_limit
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
|
|
||||||
freshservice_url = fields.Char(
|
freshservice_url = fields.Char(
|
||||||
string='Freshservice URL',
|
string='Freshservice URL',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_url',
|
config_parameter='gmn_freshservice.freshservice_url',
|
||||||
default='https://ptglobalmultimedianetwork.freshservice.com'
|
default='https://ptglobalmultimedianetwork.freshservice.com'
|
||||||
)
|
)
|
||||||
freshservice_api_key = fields.Char(
|
freshservice_api_key = fields.Char(
|
||||||
string='Freshservice API Key',
|
string='Freshservice API Key',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_api_key'
|
config_parameter='gmn_freshservice.freshservice_api_key'
|
||||||
)
|
)
|
||||||
freshservice_sync_limit = fields.Integer(
|
freshservice_sync_limit = fields.Integer(
|
||||||
string='Limit Sinkronisasi Tiket',
|
string='Limit Sinkronisasi Tiket',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_sync_limit',
|
config_parameter='gmn_freshservice.freshservice_sync_limit',
|
||||||
default=200,
|
default=200,
|
||||||
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
|
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
|
||||||
)
|
)
|
||||||
freshservice_use_mock = fields.Boolean(
|
freshservice_use_mock = fields.Boolean(
|
||||||
string='Gunakan Mock Data Engine',
|
string='Gunakan Mock Data Engine',
|
||||||
config_parameter='freshservice_ticket_audit.freshservice_use_mock',
|
config_parameter='gmn_freshservice.freshservice_use_mock',
|
||||||
# default=True,
|
default=False,
|
||||||
help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.'
|
readonly=True,
|
||||||
|
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_test_freshservice_connection(self):
|
def action_test_freshservice_connection(self):
|
||||||
|
|||||||
Binary file not shown.
@@ -31,10 +31,10 @@ class FreshserviceClient:
|
|||||||
_agent_cache = {}
|
_agent_cache = {}
|
||||||
_dept_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.base_url = (base_url or "").rstrip("/")
|
||||||
self.api_key = api_key or ""
|
self.api_key = api_key or ""
|
||||||
self.use_mock = use_mock
|
self.use_mock = False
|
||||||
|
|
||||||
def get_agent_name(self, agent_id):
|
def get_agent_name(self, agent_id):
|
||||||
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<field name="name">freshservice.ticket.stage.log.tree</field>
|
<field name="name">freshservice.ticket.stage.log.tree</field>
|
||||||
<field name="model">freshservice.ticket.stage.log</field>
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Audit Trail & Durasi Status"
|
<list string="Audit Trail & Status Duration"
|
||||||
decoration-danger="responsibility == 'internal' and is_active"
|
decoration-danger="responsibility == 'internal' and is_active"
|
||||||
decoration-warning="responsibility == 'customer' 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')">
|
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="stage_name" widget="badge"/>
|
||||||
<field name="responsibility"
|
<field name="responsibility"
|
||||||
widget="badge"
|
widget="badge"
|
||||||
decoration-danger="responsibility == 'internal'"
|
decoration-danger="responsibility in ('internal','on_hold','internal_discussion')"
|
||||||
decoration-warning="responsibility == 'customer'"
|
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_name"/>
|
||||||
<field name="actor_type"/>
|
<field name="actor_type"/>
|
||||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
@@ -36,25 +37,37 @@
|
|||||||
<form string="Detail Audit Trail">
|
<form string="Detail Audit Trail">
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group string="Informasi Status & Tanggung Jawab">
|
||||||
<field name="ticket_id" readonly="1"/>
|
<field name="ticket_id" readonly="1"/>
|
||||||
<field name="stage_name" 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_name" readonly="1"/>
|
||||||
<field name="actor_type" readonly="1"/>
|
<field name="actor_type" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group string="Durasi Kerja Operasional (08:30-17:30 WIB)">
|
||||||
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" readonly="1" string="Waktu Mulai (UTC)"/>
|
||||||
<field name="end_date" readonly="1" string="Waktu Selesai (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="working_days_effective" readonly="1" string="Hari Kerja Efektif"/>
|
||||||
<field name="duration_display" readonly="1" string="Durasi Kalender"/>
|
<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"/>
|
<field name="is_active" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Klasifikasi & 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>
|
<notebook>
|
||||||
<page string="Deskripsi Aktivitas">
|
<page string="Deskripsi Aktivitas">
|
||||||
<field name="activity_content" readonly="1"/>
|
<field name="activity_content" readonly="1"/>
|
||||||
@@ -70,20 +83,20 @@
|
|||||||
<field name="name">freshservice.ticket.stage.log.search</field>
|
<field name="name">freshservice.ticket.stage.log.search</field>
|
||||||
<field name="model">freshservice.ticket.stage.log</field>
|
<field name="model">freshservice.ticket.stage.log</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search string="Cari Log Aktivitas">
|
<search string="Search Activity Logs">
|
||||||
<field name="ticket_id"/>
|
<field name="ticket_id"/>
|
||||||
<field name="stage_name"/>
|
<field name="stage_name"/>
|
||||||
<field name="actor_name"/>
|
<field name="actor_name"/>
|
||||||
<filter name="filter_internal" string="Waiting on Us (Internal)" domain="[('responsibility', '=', 'internal')]"/>
|
<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_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_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_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_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_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_sla_paused" string="All 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_active" string="In Progress (Active)" domain="[('is_active', '=', True)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<group>
|
<group>
|
||||||
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
<filter name="group_by_ticket" string="Tiket" context="{'group_by': 'ticket_id'}"/>
|
||||||
|
|||||||
@@ -9,32 +9,21 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_sync_from_freshservice"
|
<button name="action_sync_from_freshservice"
|
||||||
type="object"
|
type="object"
|
||||||
string="Tarik Data Freshservice"
|
string="Sync Freshservice Data"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-refresh"/>
|
icon="fa-refresh"/>
|
||||||
<button name="action_open_bottleneck_wizard"
|
<button name="action_open_bottleneck_wizard"
|
||||||
type="object"
|
type="object"
|
||||||
string="Laporan Bottleneck (Excel)"
|
string="Bottleneck Report (Excel)"
|
||||||
class="btn-outline-success"
|
class="btn-outline-success"
|
||||||
icon="fa-file-excel-o"/>
|
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"/>
|
<field name="status" widget="statusbar" statusbar_visible="open,pending,resolved,closed"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<field name="is_detail_synced" invisible="1"/>
|
<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">
|
<div class="oe_title">
|
||||||
<label for="name" string="Judul / Masalah Tiket"/>
|
<label for="name" string="Ticket Subject / Issue"/>
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
<field name="name" placeholder="Contoh: SAP Client error di finance..."/>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -43,47 +32,53 @@
|
|||||||
<field name="current_responsibility" widget="badge"
|
<field name="current_responsibility" widget="badge"
|
||||||
decoration-danger="current_responsibility == 'internal'"
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
decoration-warning="current_responsibility == 'customer'"
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
|
decoration-info="current_responsibility == 'scheduled_meeting'"
|
||||||
|
decoration-muted="current_responsibility == 'third_party'"
|
||||||
decoration-success="current_responsibility == 'closed'"/>
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI METRIC CARDS SECTION -->
|
<!-- KPI METRIC CARDS SECTION -->
|
||||||
<div class="row mt-4 mb-4">
|
<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">
|
<div class="card text-center p-3 shadow-sm border-primary">
|
||||||
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
<small class="text-muted fw-bold text-uppercase">First Response</small>
|
||||||
<h4 class="mb-0 text-primary mt-1">
|
<h4 class="mb-0 text-primary mt-1"><field name="first_response_time_display" readonly="1"/></h4>
|
||||||
<field name="first_response_time_display" readonly="1"/>
|
<small class="text-secondary mt-1">Oleh: <field name="first_responder_name" readonly="1"/></small>
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">
|
|
||||||
Oleh: <field name="first_responder_name" readonly="1"/>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-danger">
|
<div class="card text-center p-3 shadow-sm border-danger">
|
||||||
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
<small class="text-muted fw-bold text-uppercase">Agent</small>
|
||||||
<h4 class="mb-0 text-danger mt-1">
|
<h4 class="mb-0 text-danger mt-1"><field name="total_agent_time_display" readonly="1"/></h4>
|
||||||
<field name="total_agent_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Waiting on Us</small>
|
<small class="text-secondary mt-1">Waiting on Us</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<div class="card text-center p-3 shadow-sm border-warning">
|
<div class="card text-center p-3 shadow-sm border-warning">
|
||||||
<small class="text-muted fw-bold text-uppercase">User</small>
|
<small class="text-muted fw-bold text-uppercase">User</small>
|
||||||
<h4 class="mb-0 text-warning mt-1">
|
<h4 class="mb-0 text-warning mt-1"><field name="total_customer_time_display" readonly="1"/></h4>
|
||||||
<field name="total_customer_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Waiting on Customer</small>
|
<small class="text-secondary mt-1">Waiting on Customer</small>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card text-center p-3 shadow-sm border-success">
|
||||||
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
<small class="text-muted fw-bold text-uppercase">Waktu Selesai</small>
|
||||||
<h4 class="mb-0 text-success mt-1">
|
<h4 class="mb-0 text-success mt-1"><field name="resolution_time_display" readonly="1"/></h4>
|
||||||
<field name="resolution_time_display" readonly="1"/>
|
|
||||||
</h4>
|
|
||||||
<small class="text-secondary mt-1">Total Resolution</small>
|
<small class="text-secondary mt-1">Total Resolution</small>
|
||||||
</div>
|
</div>
|
||||||
</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')">
|
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="stage_name" widget="badge"/>
|
||||||
<field name="responsibility" 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-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_name"/>
|
||||||
<field name="actor_type"/>
|
<field name="actor_type"/>
|
||||||
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
<field name="start_date" string="Waktu Mulai (UTC)"/>
|
||||||
@@ -159,7 +155,11 @@
|
|||||||
<field name="first_response_time_days" readonly="1"/>
|
<field name="first_response_time_days" readonly="1"/>
|
||||||
<field name="total_agent_time_days" readonly="1"/>
|
<field name="total_agent_time_days" readonly="1"/>
|
||||||
<field name="total_customer_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="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 jam disembunyikan (hide) -->
|
||||||
<field name="first_response_time_hours" invisible="1"/>
|
<field name="first_response_time_hours" invisible="1"/>
|
||||||
<field name="total_agent_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="name">freshservice.ticket.tree</field>
|
||||||
<field name="model">freshservice.ticket</field>
|
<field name="model">freshservice.ticket</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Daftar Tiket Freshservice"
|
<list string="Freshservice Tickets"
|
||||||
decoration-danger="current_responsibility == 'internal'"
|
decoration-danger="current_responsibility == 'internal'"
|
||||||
decoration-warning="current_responsibility == 'customer'"
|
decoration-warning="current_responsibility == 'customer'"
|
||||||
decoration-muted="status in ('resolved', 'closed')">
|
decoration-muted="status in ('resolved', 'closed')">
|
||||||
@@ -204,6 +204,8 @@
|
|||||||
decoration-success="current_responsibility == 'closed'"/>
|
decoration-success="current_responsibility == 'closed'"/>
|
||||||
<field name="total_agent_time_display"/>
|
<field name="total_agent_time_display"/>
|
||||||
<field name="total_customer_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="first_response_time_display"/>
|
||||||
<field name="responder_name"/>
|
<field name="responder_name"/>
|
||||||
<field name="requester_name"/>
|
<field name="requester_name"/>
|
||||||
@@ -297,17 +299,17 @@
|
|||||||
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
<filter name="filter_unassigned" string="Belum Ditugaskan" domain="[('user_id', '=', False)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="filter_waiting_us" string="Waiting on Us (Internal)" domain="[('current_responsibility', '=', 'internal')]"/>
|
<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/>
|
<separator/>
|
||||||
<filter name="filter_open_progress" string="Tiket Aktif (Belum Selesai)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
<filter name="filter_open_progress" string="Active Tickets (Not Completed)" domain="[('status', 'in', ['open', 'pending'])]"/>
|
||||||
<filter name="filter_resolved" string="Selesai (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
<filter name="filter_resolved" string="Completed (Resolved/Closed)" domain="[('status', 'in', ['resolved', 'closed'])]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="filter_mock" string="Data Mock" domain="[('is_mock_data', '=', True)]"/>
|
<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)]"/>
|
<filter name="filter_live" string="Data Live API" domain="[('is_mock_data', '=', False)]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
<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_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'}"/>
|
<filter name="group_by_requester" string="Pemohon (User)" context="{'group_by': 'requester_name'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form" position="inside">
|
<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">
|
<block title="Konfigurasi Integrasi Freshservice" name="freshservice_settings_block">
|
||||||
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
<setting id="freshservice_url_setting" help="URL Portal Freshservice perusahaan Anda">
|
||||||
<field name="freshservice_url"/>
|
<field name="freshservice_url"/>
|
||||||
@@ -21,12 +21,11 @@
|
|||||||
<field name="freshservice_sync_limit"/>
|
<field name="freshservice_sync_limit"/>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
<setting id="freshservice_mock_setting" help="Gunakan mock data sesuai format resmi Freshservice API untuk testing tanpa API Key asli">
|
<setting id="freshservice_connection_setting" help="Uji koneksi ke Live Freshservice API">
|
||||||
<field name="freshservice_use_mock"/>
|
|
||||||
<div class="mt8">
|
<div class="mt8">
|
||||||
<button name="action_test_freshservice_connection"
|
<button name="action_test_freshservice_connection"
|
||||||
type="object"
|
type="object"
|
||||||
string="Tes Koneksi / Cek Status"
|
string="Tes Koneksi Live API"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-plug"/>
|
icon="fa-plug"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +41,6 @@
|
|||||||
<field name="res_model">res.config.settings</field>
|
<field name="res_model">res.config.settings</field>
|
||||||
<field name="view_mode">form</field>
|
<field name="view_mode">form</field>
|
||||||
<field name="target">main</field>
|
<field name="target">main</field>
|
||||||
<field name="context">{'module': 'freshservice_ticket_audit'}</field>
|
<field name="context">{'module': 'gmn_freshservice'}</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
Binary file not shown.
@@ -43,6 +43,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
|
|
||||||
date_from = fields.Date(string='Dari Tanggal Dibuat')
|
date_from = fields.Date(string='Dari Tanggal Dibuat')
|
||||||
date_to = fields.Date(string='Sampai 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([
|
status_filter = fields.Selection([
|
||||||
('all', 'Semua Status'),
|
('all', 'Semua Status'),
|
||||||
@@ -80,6 +85,24 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
clean = re.sub(r'\s+', ' ', clean).strip()
|
clean = re.sub(r'\s+', ' ', clean).strip()
|
||||||
return clean
|
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):
|
def _ensure_ticket_details(self, ticket):
|
||||||
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
|
"""Selalu sync ulang detail tiket dari Freshservice setiap kali export."""
|
||||||
if self.auto_sync_detail:
|
if self.auto_sync_detail:
|
||||||
@@ -207,6 +230,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
|
'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1,
|
||||||
'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter'
|
'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({
|
kpi_box_label = workbook.add_format({
|
||||||
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
'font_size': 9, 'font_name': 'Calibri', 'bold': True,
|
||||||
'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1'
|
'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(
|
summary_html = self._build_single_ticket_sheets(
|
||||||
workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
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,
|
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:
|
else:
|
||||||
summary_html = self._build_ranking_sheets(
|
summary_html = self._build_ranking_sheets(
|
||||||
workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
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,
|
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()
|
workbook.close()
|
||||||
@@ -309,7 +339,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
def _build_single_ticket_sheets(
|
def _build_single_ticket_sheets(
|
||||||
self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
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,
|
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."""
|
"""Membuat sheet untuk analisis mendalam bottleneck tiket tunggal."""
|
||||||
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
|
ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI')
|
||||||
@@ -324,7 +355,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws1.set_column('I:I', 14)
|
ws1.set_column('I:I', 14)
|
||||||
|
|
||||||
# Header Title
|
# 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(
|
ws1.write(
|
||||||
'B3',
|
'B3',
|
||||||
f'Dicetak: {datetime.now().strftime("%d-%m-%Y %H:%M:%S")} | Dicetak oleh: {self.env.user.name}',
|
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
|
# 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('B6', 'ID Freshservice', td_fmt)
|
||||||
ws1.write('C6', f'#{t.external_ticket_id}', 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)
|
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('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('D8', 'Departemen', td_fmt)
|
||||||
ws1.write('E8', t.department_name or '-', 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('C9', t.responder_name or '-', td_fmt)
|
||||||
ws1.write('D9', 'Prioritas', 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('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('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)
|
ws1.write('E10', str(t.resolved_at or 'Masih Berjalan'), td_fmt)
|
||||||
|
|
||||||
# KPI BOXES
|
# KPI BOXES
|
||||||
@@ -404,292 +435,394 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
|
'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF',
|
||||||
'align': 'center', 'valign': 'vcenter'
|
'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('B13', 'First Response', kpi_wh_label)
|
||||||
ws1.write('C13', 'Total Waktu', kpi_wh_label)
|
ws1.write('C13', 'Total Waktu', kpi_wh_label)
|
||||||
ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_wh_label)
|
ws1.write('D13', 'Agent Responsibility', kpi_internal_label)
|
||||||
ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label)
|
ws1.write('E13', 'Requester Responsibility', kpi_customer_label)
|
||||||
ws1.write('F13', 'Total SLA Paused', kpi_wh_label)
|
ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label)
|
||||||
ws1.write('G13', 'Resolved / Idle', kpi_wh_label)
|
ws1.write('G13', 'Third Party Responsibility', kpi_third_party_label)
|
||||||
ws1.write('H13', 'Catatan Rumus', 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)
|
# 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_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
|
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()
|
report_as_of = self.as_of or fields.Datetime.now()
|
||||||
wh_it_direct = 0.0
|
wh_it_direct = wh_it_hold = wh_cust = wh_user_auto = 0.0
|
||||||
wh_internal_hold = 0.0
|
wh_needs_verif = wh_meeting = wh_vendor = wh_resolved_idle = 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 = {}
|
stage_wh_map = {}
|
||||||
|
stage_resp_map = {}
|
||||||
for s in t.stage_log_ids:
|
for s in t.stage_log_ids:
|
||||||
# Segmen aktif dihitung hingga satu timestamp cetak yang konsisten.
|
start, end = self._clipped_stage_interval(s, report_as_of)
|
||||||
s_wh = compute_working_hours(self.env, s.start_date, s.end_date or 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
|
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
|
wh_it_direct += s_wh
|
||||||
elif s.responsibility in ('on_hold', 'internal_discussion'):
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
# Sesuai Aturan 9: On Hold & Internal Discussion murni internal adalah Beban IT
|
wh_it_hold += s_wh
|
||||||
wh_internal_hold += s_wh
|
elif resp == 'customer':
|
||||||
elif s.responsibility == 'customer':
|
|
||||||
wh_cust += s_wh
|
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
|
wh_meeting += s_wh
|
||||||
elif s.responsibility == 'third_party':
|
elif resp in ('third_party', 'external_activity'):
|
||||||
wh_vendor += s_wh
|
wh_vendor += s_wh
|
||||||
elif s.responsibility == 'external_activity':
|
elif resp == 'resolved_idle':
|
||||||
wh_other_paused += s_wh
|
|
||||||
elif s.responsibility == 'resolved_idle':
|
|
||||||
wh_resolved_idle += s_wh
|
wh_resolved_idle += s_wh
|
||||||
|
|
||||||
wh_agent = wh_it_direct + wh_internal_hold
|
wh_agent = wh_it_direct + wh_it_hold
|
||||||
wh_paused = wh_meeting + wh_vendor + wh_other_paused
|
wh_requester = wh_cust + wh_user_auto + wh_needs_verif
|
||||||
wh_total = sum(stage_wh_map.values())
|
wh_total = sum(stage_wh_map.values())
|
||||||
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
|
ws1.write('B14', format_working_duration(wh_fr), kpi_box_val)
|
||||||
ws1.write('C14', format_working_duration(wh_total), 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('D14', format_working_duration(wh_agent), kpi_internal_val)
|
||||||
ws1.write('E14', format_working_duration(wh_cust), kpi_box_val)
|
ws1.write('E14', format_working_duration(wh_requester), kpi_customer_val)
|
||||||
ws1.write('F14', format_working_duration(wh_paused), kpi_box_val)
|
ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val)
|
||||||
ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_val)
|
ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val)
|
||||||
ws1.write('H14', 'Senin-Jumat, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 Jam/Hari)\nDB UTC → +7 WIB', kpi_box_val)
|
ws1.write('H14', 'Monday-Friday, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 hours/day)\nDB UTC → +7 WIB', kpi_box_val)
|
||||||
|
|
||||||
# REKAPITULASI BEBAN TANGGUNG JAWAB — harus identik dengan total Sheet 2.
|
# RESPONSIBILITY RECAP — must match the Sheet 2 total.
|
||||||
ws1.merge_range('B16:I16', 'REKAPITULASI BEBAN TANGGUNG JAWAB & MATRIKS BOTTLENECK', section_fmt)
|
ws1.merge_range('B16:I16', 'RESPONSIBILITY RECAP & BOTTLENECK MATRIX', section_fmt)
|
||||||
ws1.write('B17', 'Kategori Beban', th_fmt)
|
ws1.write('B17', 'Responsibility Bucket', th_fmt)
|
||||||
ws1.merge_range('C17:F17', 'Keterangan Operasional', th_fmt)
|
ws1.merge_range('C17:F17', 'Operational Description', th_fmt)
|
||||||
ws1.write('G17', 'Jam Kerja', th_fmt)
|
ws1.write('G17', 'Working Hours', th_fmt)
|
||||||
ws1.write('H17', 'Hari Kerja Efektif', th_fmt)
|
ws1.write('H17', 'Effective Workdays', th_fmt)
|
||||||
ws1.write('I17', '% Beban', th_fmt)
|
ws1.write('I17', '% of Load', th_fmt)
|
||||||
recap_rows = [
|
recap_rows = [
|
||||||
('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / In Progress).', wh_it_direct),
|
('Agent Active Work', 'Direct Agent handling while the ticket is Open or 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),
|
('Agent Hold & Discussion', 'Hold or internal discussion that remains Agent responsibility.', wh_it_hold),
|
||||||
('Waiting on User (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust),
|
('Waiting on Requester', 'Waiting for the requester to reply, confirm, or decide.', wh_cust),
|
||||||
('Tunggu Bersama (Scheduled Meeting)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting),
|
('Waiting on User After Auto-Resolve', 'Waiting for user action after a system auto-resolve workflow.', wh_user_auto),
|
||||||
('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor),
|
('Waiting on User Verification', 'An agent requested user confirmation while the ticket was on hold.', wh_needs_verif),
|
||||||
('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused),
|
('Scheduled Meeting (Shared)', 'Waiting for a meeting agreed by both parties.', wh_meeting),
|
||||||
('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle),
|
('Waiting on Third Party', 'Escalation or work performed by an external vendor or third party.', wh_vendor),
|
||||||
('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total),
|
('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):
|
# xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 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)))
|
# 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.write(recap_row, 1, label, label_fmt)
|
||||||
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
|
ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt)
|
||||||
ws1.write(recap_row, 6, hours, td_dec_fmt)
|
excel_row = recap_row + 1
|
||||||
ws1.write(recap_row, 7, round(hours / 8.0, 2), td_dec_fmt)
|
ws1.write_number(recap_row, 6, round(hours, 2), td_dec_fmt)
|
||||||
ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_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
|
# TABEL RANKING BOTTLENECK PER STAGE
|
||||||
# Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga
|
ws1.merge_range('B36:I36', 'BOTTLENECK RANKING BY STAGE (Longest First)', section_fmt)
|
||||||
# ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25.
|
headers_bn = ['Rank', 'Stage / Status', 'Responsibility', 'Actor', 'Effective Workdays', 'Working Hours', '% Contribution', 'Status']
|
||||||
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):
|
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)
|
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)
|
total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01)
|
||||||
|
|
||||||
row = 30
|
row = 37
|
||||||
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):
|
for rank_idx, stage in enumerate(sorted_stages, start=1):
|
||||||
stage_wh = stage_wh_map.get(stage.id, 0.0)
|
stage_wh = stage_wh_map.get(stage.id, 0.0)
|
||||||
stage_wd_eff = round(stage_wh / 8.0, 2)
|
stage_wd_eff = round(stage_wh / 8.0, 2)
|
||||||
pct = (stage_wh / total_wh_ticket) if total_wh_ticket else 0.0
|
resp = stage_resp_map.get(stage.id, self._effective_responsibility(stage))
|
||||||
if stage.responsibility == 'internal':
|
if resp == 'internal':
|
||||||
resp_label = 'Waiting on Us (Internal)'
|
resp_label = 'Agent Active Work'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = bn_badge_fmt
|
||||||
elif stage.responsibility == 'customer':
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
resp_label = 'Waiting on User (Requester)'
|
resp_label = 'Agent Hold & Discussion'
|
||||||
|
resp_fmt = bn_badge_fmt
|
||||||
|
elif resp == 'customer':
|
||||||
|
resp_label = 'Waiting on Requester'
|
||||||
resp_fmt = normal_badge_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'on_hold':
|
elif resp == 'user_after_auto_resolve':
|
||||||
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
|
resp_label = 'Waiting on User After Auto-Resolve'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'third_party':
|
elif resp == 'needs_verification':
|
||||||
resp_label = 'Waiting on Third Party (SLA is Paused)'
|
resp_label = 'Waiting on User Verification'
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
elif stage.responsibility == 'internal_discussion':
|
elif resp == 'scheduled_meeting':
|
||||||
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
|
resp_label = 'Scheduled Meeting (Shared)'
|
||||||
resp_fmt = bn_badge_fmt
|
resp_fmt = scheduled_meeting_fmt
|
||||||
elif stage.responsibility == 'scheduled_meeting':
|
elif resp in ('third_party', 'external_activity'):
|
||||||
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
|
resp_label = 'Waiting on Third Party'
|
||||||
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
|
resp_fmt = sla_paused_fmt or td_center_fmt
|
||||||
|
elif resp == 'resolved_idle':
|
||||||
|
resp_label = 'Resolved / Idle Time'
|
||||||
|
resp_fmt = td_center_fmt
|
||||||
else:
|
else:
|
||||||
resp_label = 'Resolved (Ticket Closed/Idle)'
|
resp_label = 'Waiting on Requester'
|
||||||
resp_fmt = sla_paused_fmt or td_center_fmt
|
resp_fmt = normal_badge_fmt
|
||||||
is_primary = (rank_idx == 1 and stage_wh > 0.0)
|
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, 2, stage.stage_name or '-', td_fmt)
|
||||||
ws1.write(row, 3, resp_label, resp_fmt)
|
ws1.write(row, 3, resp_label, resp_fmt)
|
||||||
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
|
ws1.write(row, 4, stage.actor_name or '-', td_fmt)
|
||||||
ws1.write(row, 5, stage_wd_eff, td_dec_fmt)
|
ws1.write_number(row, 5, stage_wd_eff, td_dec_fmt)
|
||||||
ws1.write(row, 6, stage_wh, td_dec_fmt)
|
ws1.write_number(row, 6, round(stage_wh, 2), td_dec_fmt)
|
||||||
ws1.write(row, 7, pct, td_pct_fmt)
|
ws1.write_formula(row, 7, f'=IF($G$26=0,0,G{row + 1}/$G$26)', td_pct_fmt)
|
||||||
ws1.write(row, 8, 'Sedang Berjalan' if stage.is_active else 'Selesai', td_center_fmt)
|
ws1.write(row, 8, 'In Progress' if stage.is_active else 'Completed', td_center_fmt)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# KESIMPULAN HIGHLIGHT BOX
|
# SHEET 2: AUDIT TRAIL & CHRONOLOGY
|
||||||
row += 1
|
ws2 = workbook.add_worksheet('Audit Trail & Chronology')
|
||||||
# ws1.merge_range(row, 1, row, 8, 'KESIMPULAN BOTTLENECK UNTUK BAHAN MEETING', section_fmt)
|
ws2.set_column('A:A', 4)
|
||||||
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('B:B', 6)
|
||||||
ws2.set_column('C:C', 22)
|
ws2.set_column('C:D', 18)
|
||||||
ws2.set_column('D:D', 22)
|
ws2.set_column('E:F', 24)
|
||||||
ws2.set_column('E:E', 22)
|
ws2.set_column('G:G', 28)
|
||||||
ws2.set_column('F:F', 26)
|
ws2.set_column('H:H', 14)
|
||||||
ws2.set_column('G:G', 16)
|
ws2.set_column('I:I', 20)
|
||||||
ws2.set_column('H:H', 22)
|
ws2.set_column('J:K', 16)
|
||||||
ws2.set_column('I:I', 18)
|
ws2.set_column('L:L', 20)
|
||||||
ws2.set_column('J:J', 18)
|
ws2.set_column('M:M', 32)
|
||||||
ws2.set_column('K:K', 35)
|
ws2.set_column('N:N', 16)
|
||||||
ws2.set_column('L:L', 55)
|
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 = [
|
headers_ws2 = [
|
||||||
'No', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
|
'No', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
|
||||||
'Tanggung Jawab', 'Tipe Pengguna', 'Nama Pengguna',
|
'Effective Status', 'Task Owner', 'Actor Type', 'Actor Name',
|
||||||
'Hari Kerja Efektif', 'Jam Kerja',
|
'Effective Workdays', 'Working Hours', 'Delay Cause',
|
||||||
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
|
'Classification Basis', 'User Verification Required',
|
||||||
|
'Activity Details', 'Reply / Message Content'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers_ws2, start=1):
|
for col_idx, h in enumerate(headers_ws2, start=1):
|
||||||
ws2.write(3, col_idx, h, th_fmt)
|
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
|
row2 = 4
|
||||||
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1):
|
for idx2, log in enumerate(t.stage_log_ids.sorted('start_date'), start=1):
|
||||||
if log.responsibility == 'internal':
|
resp = stage_resp_map.get(log.id, self._effective_responsibility(log))
|
||||||
resp_label = 'Waiting on Us (Internal)'
|
if resp == 'internal':
|
||||||
elif log.responsibility == 'customer':
|
resp_label = 'Agent Active Work'
|
||||||
resp_label = 'Waiting on User (Customer)'
|
resp_fmt = bn_badge_fmt
|
||||||
elif log.responsibility == 'on_hold':
|
elif resp in ('it_hold', 'on_hold', 'internal_discussion'):
|
||||||
resp_label = 'On Hold (Internal Hold / Waiting on Us)'
|
resp_label = 'Agent Hold & Discussion'
|
||||||
elif log.responsibility == 'third_party':
|
resp_fmt = bn_badge_fmt
|
||||||
resp_label = 'Waiting on Third Party (SLA is Paused)'
|
elif resp == 'customer':
|
||||||
elif log.responsibility == 'internal_discussion':
|
resp_label = 'Waiting on Requester'
|
||||||
resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)'
|
resp_fmt = normal_badge_fmt
|
||||||
elif log.responsibility == 'scheduled_meeting':
|
elif resp == 'user_after_auto_resolve':
|
||||||
resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'
|
resp_label = 'Waiting on User After Auto-Resolve'
|
||||||
elif log.responsibility == 'external_activity':
|
resp_fmt = normal_badge_fmt
|
||||||
resp_label = 'External Activity (SLA is Paused)'
|
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:
|
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)
|
msg_body = self._get_stage_conversation_body(log, t)
|
||||||
raw_act_main = log.activity_content or ''
|
raw_act_main = log.activity_content or ''
|
||||||
if 'Isi Pesan:' in raw_act_main:
|
if 'Isi Pesan:' in raw_act_main:
|
||||||
raw_act_main = raw_act_main.split('Isi Pesan:')[0].strip()
|
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 = stage_wh_map.get(log.id, 0.0)
|
||||||
wh = compute_working_hours(self.env, log.start_date, log.end_date or report_as_of)
|
|
||||||
wd_eff = round(wh / 8.0, 2)
|
wd_eff = round(wh / 8.0, 2)
|
||||||
|
cause_label = dict(log._fields['delay_cause'].selection).get(log.delay_cause, log.delay_cause or 'None')
|
||||||
# Tampilkan waktu dalam WIB (UTC+7) untuk kemudahan baca
|
verif_str = 'Yes' if (log.needs_verification or resp == 'needs_verification') else 'No'
|
||||||
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, 1, idx2, td_center_fmt)
|
||||||
ws2.write(row2, 2, _to_wib_str(log.start_date), 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, 3, _to_wib_str(log.end_date), td_center_fmt)
|
||||||
ws2.write(row2, 4, log.stage_name or '-', td_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, 5, log.effective_status or log.stage_name or '-', td_fmt)
|
||||||
ws2.write(row2, 6, log.actor_type or '-', td_center_fmt)
|
ws2.write(row2, 6, resp_label, resp_fmt)
|
||||||
ws2.write(row2, 7, log.actor_name or '-', td_fmt)
|
ws2.write(row2, 7, log.actor_type or '-', td_center_fmt)
|
||||||
ws2.write(row2, 8, wd_eff, td_dec_fmt)
|
ws2.write(row2, 8, log.actor_name or '-', td_fmt)
|
||||||
ws2.write(row2, 9, wh, td_dec_fmt)
|
ws2.write_number(row2, 9, wd_eff, td_dec_fmt)
|
||||||
ws2.write(row2, 10, self._strip_html(raw_act_main) or '-', td_fmt)
|
ws2.write_number(row2, 10, round(wh, 2), td_dec_fmt)
|
||||||
ws2.write(row2, 11, msg_body or '-', td_wrap_fmt if msg_body else td_center_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
|
row2 += 1
|
||||||
|
|
||||||
# Grand total menggunakan sumber data yang sama dengan Sheet 1 (stage_wh_map).
|
# Grand total using Excel formulas
|
||||||
ws2.merge_range(row2, 1, row2, 7, 'GRAND TOTAL UMUR TIKET', section_fmt)
|
ws2.merge_range(row2, 1, row2, 8, 'GRAND TOTAL TICKET LIFETIME', section_fmt)
|
||||||
ws2.write(row2, 8, round(wh_total / 8.0, 2), td_dec_fmt)
|
ws2.write_formula(row2, 9, f'=SUM(J5:J{row2})', td_dec_fmt)
|
||||||
ws2.write(row2, 9, wh_total, td_dec_fmt)
|
ws2.write_formula(row2, 10, f'=SUM(K5:K{row2})', 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)
|
ws2.merge_range(row2, 11, row2, 15, 'Matches Sheet 1: all stage logs calculated using effective business hours.', td_fmt)
|
||||||
|
|
||||||
# SHEET 3: RIWAYAT PERCAKAPAN
|
# SHEET 3: CONVERSATIONS & NOTES
|
||||||
ws3 = workbook.add_worksheet('Percakapan & Respon')
|
ws3 = workbook.add_worksheet('Conversations & Notes')
|
||||||
ws3.set_column('A:A', 5)
|
ws3.set_column('A:A', 5)
|
||||||
ws3.set_column('B:B', 8)
|
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('D:D', 22)
|
||||||
ws3.set_column('E:E', 24)
|
ws3.set_column('E:E', 24)
|
||||||
ws3.set_column('F:F', 55)
|
ws3.set_column('F:F', 55)
|
||||||
|
|
||||||
ws3.merge_range('B2:F2', f'RIWAYAT PERCAKAPAN & CATATAN TIKET #{t.external_ticket_id}', title_fmt)
|
ws3.merge_range('B2:F2', f'CONVERSATION HISTORY & TICKET NOTES #{t.external_ticket_id}', title_fmt)
|
||||||
headers_ws3 = ['No', 'Waktu Dibuat', 'Tipe Pengirim', 'Pengirim', 'Isi Pesan']
|
headers_ws3 = ['No', 'Created Time (WIB)', 'Sender Type', 'Sender Name', 'Message Content']
|
||||||
for col_idx, h in enumerate(headers_ws3, start=1):
|
for col_idx, h in enumerate(headers_ws3, start=1):
|
||||||
ws3.write(3, col_idx, h, th_fmt)
|
ws3.write(3, col_idx, h, th_fmt)
|
||||||
|
|
||||||
row3 = 4
|
row3 = 4
|
||||||
for idx3, conv in enumerate(t.conversation_ids.sorted('created_at'), start=1):
|
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, 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, 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, 4, conv.user_name or '-', td_fmt)
|
||||||
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
|
ws3.write(row3, 5, self._strip_html(conv.body_text) or '-', td_fmt)
|
||||||
row3 += 1
|
row3 += 1
|
||||||
|
|
||||||
# Siapkan Ringkasan HTML untuk tampilan di Pop-up Wizard
|
# HTML summary for wizard pop-up dialog
|
||||||
html_out = f"""
|
html_out = f"""
|
||||||
<div class="alert alert-info border shadow-sm">
|
<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>
|
<h4 class="alert-heading fw-bold mb-2">📊 Ticket Bottleneck Analysis #{t.external_ticket_id}</h4>
|
||||||
<p class="mb-1"><strong>Subjek:</strong> {t.name or '-'}</p>
|
<p class="mb-1"><strong>Subject:</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>
|
<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"/>
|
<hr class="my-2"/>
|
||||||
<div class="row text-center mb-3">
|
<div class="row text-center mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border">
|
<div class="bg-light p-2 rounded border">
|
||||||
<small class="text-muted d-block">TOTAL WAKTU</small>
|
<small class="text-muted d-block">TOTAL DURATION</small>
|
||||||
<strong class="text-dark fs-5">{t.resolution_time_days} Hari</strong>
|
<strong class="text-dark fs-5">{t.resolution_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-danger">
|
<div class="bg-light p-2 rounded border border-danger">
|
||||||
<small class="text-danger d-block fw-bold">WAITING ON US (IT)</small>
|
<small class="text-danger d-block fw-bold">AGENT RESPONSIBILITY</small>
|
||||||
<strong class="text-danger fs-5">{t.total_agent_time_days} Hari</strong>
|
<strong class="text-danger fs-5">{t.total_agent_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-warning">
|
<div class="bg-light p-2 rounded border border-warning">
|
||||||
<small class="text-warning d-block fw-bold">WAITING ON USER</small>
|
<small class="text-warning d-block fw-bold">REQUESTER RESPONSIBILITY</small>
|
||||||
<strong class="text-warning fs-5">{t.total_customer_time_days} Hari</strong>
|
<strong class="text-warning fs-5">{t.total_customer_time_days} Days</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="bg-light p-2 rounded border border-primary">
|
<div class="bg-light p-2 rounded border border-primary">
|
||||||
<small class="text-primary d-block fw-bold">FIRST RESPONSE</small>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
return html_out
|
||||||
# <div class="alert alert-danger mb-0">
|
# <div class="alert alert-danger mb-0">
|
||||||
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
|
# <strong>🚨 Temuan Bottleneck Utama:</strong><br/>
|
||||||
# {primary_bn_text}
|
# {primary_bn_text}
|
||||||
@@ -699,10 +832,12 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
def _build_ranking_sheets(
|
def _build_ranking_sheets(
|
||||||
self, workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt,
|
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,
|
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)."""
|
"""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('A:A', 5)
|
||||||
ws.set_column('B:B', 8)
|
ws.set_column('B:B', 8)
|
||||||
ws.set_column('C:C', 14)
|
ws.set_column('C:C', 14)
|
||||||
@@ -721,20 +856,20 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws.set_column('P:P', 16)
|
ws.set_column('P:P', 16)
|
||||||
ws.set_column('Q:Q', 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(
|
ws.write(
|
||||||
'B3',
|
'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
|
subtitle_fmt
|
||||||
)
|
)
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
'Rank', 'ID Freshservice', 'Judul Tiket', 'Pemohon (User)', 'Departemen', 'Agen IT',
|
'Rank', 'Freshservice ID', 'Ticket Subject', 'Requester (User)', 'Department', 'Agent Agent',
|
||||||
'Status',
|
'Status',
|
||||||
'Total Hari Kalender', 'Total Jam Kerja (08:30-17:30)', 'Total Hari Kerja Efektif',
|
'Total Calendar Days', 'Total Working Hours (08:30-17:30)', 'Total Effective Workdays',
|
||||||
'Waktu IT (Hari Kalender)', 'Waktu IT (Jam Kerja)',
|
'Agent Time (Calendar Days)', 'Agent Time (Working Hours)',
|
||||||
'Waktu User (Hari Kalender)', 'Waktu User (Jam Kerja)',
|
'User Time (Calendar Days)', 'User Time (Working Hours)',
|
||||||
'Bottleneck Utama (Stage)', 'Durasi Bottleneck (Hari Kalender)', 'Durasi Bottleneck (Jam Kerja)'
|
'Primary Bottleneck (Stage)', 'Bottleneck Duration (Calendar Days)', 'Bottleneck Duration (Working Hours)'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers, start=1):
|
for col_idx, h in enumerate(headers, start=1):
|
||||||
ws.write(4, col_idx, h, th_fmt)
|
ws.write(4, col_idx, h, th_fmt)
|
||||||
@@ -753,7 +888,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
top_wh = 0.0
|
top_wh = 0.0
|
||||||
if top_stage:
|
if top_stage:
|
||||||
top_stage_name = top_stage[0].stage_name or '-'
|
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_days = top_stage[0].duration_days
|
||||||
top_wh = compute_working_hours(self.env, top_stage[0].start_date, top_stage[0].end_date)
|
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
|
t_wh_cust = 0.0
|
||||||
for s in t.stage_log_ids:
|
for s in t.stage_log_ids:
|
||||||
s_wh = compute_working_hours(self.env, s.start_date, s.end_date)
|
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
|
t_wh_agent += s_wh
|
||||||
elif s.responsibility == 'customer':
|
elif resp in ('customer', 'user_after_auto_resolve', 'needs_verification'):
|
||||||
t_wh_cust += s_wh
|
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)
|
t_wd_eff = round(t_wh_total / 8.0, 2)
|
||||||
|
|
||||||
if rank_idx <= 5:
|
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, 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, 2, f"#{t.external_ticket_id}", td_center_fmt)
|
||||||
@@ -795,7 +930,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# SHEET 2: DETAIL STAGE LOG SEMUA TIKET
|
# 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('A:A', 5)
|
||||||
ws2.set_column('B:B', 14)
|
ws2.set_column('B:B', 14)
|
||||||
ws2.set_column('C:C', 22)
|
ws2.set_column('C:C', 22)
|
||||||
@@ -810,13 +945,13 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws2.set_column('L:L', 32)
|
ws2.set_column('L:L', 32)
|
||||||
ws2.set_column('M:M', 50)
|
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 = [
|
headers2 = [
|
||||||
'ID Tiket', 'Waktu Mulai (WIB)', 'Waktu Selesai (WIB)', 'Stage / Status',
|
'Ticket ID', 'Start Time (WIB)', 'End Time (WIB)', 'Stage / Status',
|
||||||
'Tanggung Jawab', 'Pengguna',
|
'Responsibility', 'Actor',
|
||||||
'Hari Kalender', 'Jam Kalender',
|
'Calendar Days', 'Calendar Hours',
|
||||||
'Jam Kerja (08:30-17:30)', 'Hari Kerja Efektif',
|
'Working Hours (08:30-17:30)', 'Effective Workdays',
|
||||||
'Keterangan Aktivitas', 'Isi Balasan / Pesan'
|
'Activity Details', 'Reply / Message Content'
|
||||||
]
|
]
|
||||||
for col_idx, h in enumerate(headers2, start=1):
|
for col_idx, h in enumerate(headers2, start=1):
|
||||||
ws2.write(3, col_idx, h, th_fmt)
|
ws2.write(3, col_idx, h, th_fmt)
|
||||||
@@ -825,15 +960,23 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
for t in sorted_tickets:
|
for t in sorted_tickets:
|
||||||
for log in t.stage_log_ids.sorted('start_date'):
|
for log in t.stage_log_ids.sorted('start_date'):
|
||||||
if log.responsibility == 'internal':
|
if log.responsibility == 'internal':
|
||||||
resp_label = 'Internal (Waiting on Us)'
|
resp_label = 'Agent Active Work'
|
||||||
elif log.responsibility == 'customer':
|
elif log.responsibility == 'customer':
|
||||||
resp_label = 'Customer (Waiting on User)'
|
resp_label = 'Waiting on Requester'
|
||||||
elif log.responsibility == 'on_hold':
|
elif log.responsibility in ('on_hold', 'it_hold', 'internal_discussion'):
|
||||||
resp_label = 'On Hold (SLA is Paused)'
|
resp_label = 'Agent Hold & Discussion'
|
||||||
elif log.responsibility == 'third_party':
|
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:
|
else:
|
||||||
resp_label = 'Waiting on Internal Discussion (SLA is Paused)'
|
resp_label = 'Waiting on Requester'
|
||||||
msg_body = self._get_stage_conversation_body(log, t)
|
msg_body = self._get_stage_conversation_body(log, t)
|
||||||
raw_act_main = log.activity_content or ''
|
raw_act_main = log.activity_content or ''
|
||||||
if 'Isi Pesan:' in raw_act_main:
|
if 'Isi Pesan:' in raw_act_main:
|
||||||
@@ -846,7 +989,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
from datetime import timedelta as _td2
|
from datetime import timedelta as _td2
|
||||||
def _wib(dt):
|
def _wib(dt):
|
||||||
if not dt:
|
if not dt:
|
||||||
return 'Aktif'
|
return 'Active'
|
||||||
try:
|
try:
|
||||||
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
|
return (dt + _td2(hours=7)).strftime('%Y-%m-%d %H:%M')
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -856,7 +999,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
|||||||
ws2.write(row2, 2, _wib(log.start_date), 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, 3, _wib(log.end_date), td_center_fmt)
|
||||||
ws2.write(row2, 4, log.stage_name or '-', td_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, 6, log.actor_name or '-', td_fmt)
|
||||||
ws2.write(row2, 7, calc_log_days, td_num_fmt)
|
ws2.write(row2, 7, calc_log_days, td_num_fmt)
|
||||||
ws2.write(row2, 8, log.duration_hours, td_dec_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))
|
avg_days = sum(sorted_tickets.mapped('resolution_time_days')) / max(1, len(sorted_tickets))
|
||||||
html_out = f"""
|
html_out = f"""
|
||||||
<div class="alert alert-info border shadow-sm">
|
<div class="alert alert-info border shadow-sm">
|
||||||
<h4 class="alert-heading fw-bold mb-2">📋 Laporan Ranking Bottleneck Multi-Tiket Berhasil Dibuat</h4>
|
<h4 class="alert-heading fw-bold mb-2">📋 Multi-Ticket Bottleneck Ranking Report Generated</h4>
|
||||||
<p class="mb-1">Total Tiket yang Dievaluasi: <strong>{len(sorted_tickets)} Tiket</strong></p>
|
<p class="mb-1">Total Evaluated Tickets: <strong>{len(sorted_tickets)} Tickets</strong></p>
|
||||||
<p class="mb-2">Rata-rata Waktu Penyelesaian: <strong>{avg_days:.1f} Hari Kalender</strong></p>
|
<p class="mb-2">Average Resolution Time: <strong>{avg_days:.1f} Calendar Days</strong></p>
|
||||||
<hr class="my-2"/>
|
<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">
|
<ul class="mb-2">
|
||||||
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
|
{''.join(f'<li>{item}</li>' for item in slowest_tickets)}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
"""
|
"""
|
||||||
return html_out
|
return html_out
|
||||||
|
return html_out
|
||||||
|
|||||||
Reference in New Issue
Block a user