diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc index 975f7cc..120e87f 100644 Binary files a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_conversation_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc index c985486..090e26f 100644 Binary files a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_stage_log_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc index 8eff2b6..199746e 100644 Binary files a/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc and b/gmn_freshservice/models/__pycache__/gmn_freshservice_ticket_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc b/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc index 02e777a..2f5be3e 100644 Binary files a/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc and b/gmn_freshservice/models/__pycache__/gmn_res_config_settings_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py index 8583acd..5e3016f 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_conversation_v01.py @@ -25,7 +25,7 @@ class FreshserviceTicketConversation(models.Model): sender_type = fields.Selection([ ('agent', 'IT Support (Agent)'), - ('customer', 'User (Customer)'), + ('customer', 'User (Requester)'), ], string='Tipe Pengirim', compute='_compute_sender_type', store=True) body_text = fields.Text(string='Isi Pesan') diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py index e83b778..1bc77b9 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py @@ -260,7 +260,7 @@ class FreshserviceTicketStageLog(models.Model): ('on_hold', 'On Hold (SLA is Paused)'), ('third_party', 'Waiting on Third Party (SLA is Paused)'), ('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'), - ('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'), + ('scheduled_meeting', 'Scheduled Meeting (Waiting Together - SLA is Paused)'), ('external_activity', 'External Activity (SLA is Paused)'), ('resolved_idle', 'Resolved (Ticket Closed/Idle)'), ], string='Tanggung Jawab', required=True, default='internal', index=True) diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py index cb5e5d6..4cdf098 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py @@ -64,7 +64,7 @@ class FreshserviceTicket(models.Model): first_responded_at = fields.Datetime(string='Waktu First Response') resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)') - is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, readonly=True) + is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True) is_detail_synced = fields.Boolean( string='Detail Lengkap Tersinkron', default=False, @@ -134,6 +134,40 @@ class FreshserviceTicket(models.Model): store=True ) + shared_wait_time_days = fields.Integer( + string='Scheduled Meeting (Days)', + compute='_compute_time_metrics', + store=True + ) + shared_wait_time_hours = fields.Float( + string='Scheduled Meeting (Hours)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + shared_wait_time_display = fields.Char( + string='Scheduled Meeting', + compute='_compute_time_metrics', + store=True + ) + + third_party_time_days = fields.Integer( + string='Waiting on Third Party (Hari)', + compute='_compute_time_metrics', + store=True + ) + third_party_time_hours = fields.Float( + string='Waiting on Third Party (Jam)', + compute='_compute_time_metrics', + store=True, + digits=(12, 2) + ) + third_party_time_display = fields.Char( + string='Waiting on Third Party', + compute='_compute_time_metrics', + store=True + ) + resolution_time_days = fields.Integer( string='Waktu Penyelesaian (Hari)', compute='_compute_time_metrics', @@ -153,7 +187,7 @@ class FreshserviceTicket(models.Model): current_responsibility = fields.Selection([ ('internal', 'Waiting on Us (Internal)'), - ('customer', 'Waiting on User (Customer)'), + ('customer', 'Waiting on User (Requester)'), ('closed', 'Tiket Selesai'), ], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True) @@ -171,7 +205,7 @@ class FreshserviceTicket(models.Model): @api.depends( 'ticket_created_at', 'first_responded_at', 'resolved_at', 'status', 'is_detail_synced', - 'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.is_active' + 'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active' ) def _compute_time_metrics(self): for rec in self: @@ -190,10 +224,15 @@ class FreshserviceTicket(models.Model): rec.first_response_time_display = "Belum Ada Respon" # 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender. - agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'internal') + # Mengikuti Aturan 9: Internal Hold & Internal Discussion dibebankan ke Agen (Waiting on Us). + agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility in ('internal', 'on_hold', 'internal_discussion')) cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer') + shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'scheduled_meeting') + third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'third_party') agent_days = round(agent_hours / 8.0, 2) cust_days = round(cust_hours / 8.0, 2) + shared_wait_days = round(shared_wait_hours / 8.0, 2) + third_party_days = round(third_party_hours / 8.0, 2) # Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT. total_working_hours = sum(l.working_hours for l in rec.stage_log_ids) @@ -207,6 +246,14 @@ class FreshserviceTicket(models.Model): rec.total_customer_time_days = round(cust_days) rec.total_customer_time_display = format_duration_days(round(cust_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + rec.shared_wait_time_hours = round(shared_wait_hours, 2) + rec.shared_wait_time_days = round(shared_wait_days) + rec.shared_wait_time_display = format_duration_days(round(shared_wait_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + + rec.third_party_time_hours = round(third_party_hours, 2) + rec.third_party_time_days = round(third_party_days) + rec.third_party_time_display = format_duration_days(round(third_party_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-" + # 3. Hitung Resolution Time if rec.ticket_created_at and rec.resolved_at: delta_res = rec.resolved_at - rec.ticket_created_at @@ -289,9 +336,9 @@ class FreshserviceTicket(models.Model): return get_param = self.env['ir.config_parameter'].sudo().get_param - base_url = get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') - api_key = get_param('freshservice_ticket_audit.freshservice_api_key', '') - use_mock = get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) + base_url = get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = get_param('gmn_freshservice.freshservice_api_key', '') + use_mock = False client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) payload = client.fetch_ticket_full_data(self.external_ticket_id) @@ -657,7 +704,7 @@ class FreshserviceTicket(models.Model): 'riwayat percakapan lama dipertahankan.', self.external_ticket_id, ) - mode_text = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "LIVE FRESHSERVICE API" + mode_text = "LIVE FRESHSERVICE API" return { 'type': 'ir.actions.client', 'tag': 'display_notification', @@ -669,25 +716,6 @@ class FreshserviceTicket(models.Model): } } - def action_toggle_mock_mode(self): - """Toggle instan mode antara Mock dan Live API""" - param_obj = self.env['ir.config_parameter'].sudo() - current = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) - new_val = not current - param_obj.set_param('freshservice_ticket_audit.freshservice_use_mock', str(new_val)) - - status_label = "MOCK DATA (Simulasi)" if new_val else "LIVE API FRESHSERVICE" - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'Mode Berhasil Diubah', - 'message': f'Sistem sekarang beralih ke: {status_label}. Klik tombol "Tarik Data Freshservice" untuk menyegarkan data.', - 'type': 'info', - 'sticky': False, - } - } - # ========================================================================= # AUTO-FETCH SAAT BUKA HALAMAN LIST TIKET & MULTI-USER SYNCHRONIZATION # ========================================================================= @@ -711,14 +739,14 @@ class FreshserviceTicket(models.Model): param_obj = self.env['ir.config_parameter'].sudo() if not limit: try: - limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200) + limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200) except (ValueError, TypeError): limit = 200 if limit <= 0: limit = 200 now_ts = fields.Datetime.now().timestamp() - last_sync = param_obj.get_param('freshservice_ticket_audit.last_list_sync_time') + last_sync = param_obj.get_param('gmn_freshservice.last_list_sync_time') if last_sync: try: # Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir @@ -726,14 +754,14 @@ class FreshserviceTicket(models.Model): return 0 except Exception: pass - param_obj.set_param('freshservice_ticket_audit.last_list_sync_time', str(now_ts)) + param_obj.set_param('gmn_freshservice.last_list_sync_time', str(now_ts)) - base_url = param_obj.get_param('freshservice_ticket_audit.freshservice_url', 'https://servicedesk.lyman.co.id') - api_key = param_obj.get_param('freshservice_ticket_audit.freshservice_api_key', '') - use_mock = param_obj.get_param('freshservice_ticket_audit.freshservice_use_mock', 'True') in ('True', '1', True) + base_url = param_obj.get_param('gmn_freshservice.freshservice_url', 'https://servicedesk.lyman.co.id') + api_key = param_obj.get_param('gmn_freshservice.freshservice_api_key', '') + use_mock = False try: - client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock) + client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=False) tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id) except Exception as e: _logger.warning("Auto-fetch Freshservice gagal: %s", e) @@ -884,10 +912,10 @@ class FreshserviceTicket(models.Model): except Exception as e: _logger.warning("Auto fetch saat buka menu tiket: %s", e) - action = self.env['ir.actions.act_window']._for_xml_id('freshservice_ticket_audit.action_freshservice_ticket') + action = self.env['ir.actions.act_window']._for_xml_id('gmn_freshservice.action_freshservice_ticket') param_obj = self.env['ir.config_parameter'].sudo() try: - sync_limit = int(param_obj.get_param('freshservice_ticket_audit.freshservice_sync_limit', 200) or 200) + sync_limit = int(param_obj.get_param('gmn_freshservice.freshservice_sync_limit', 200) or 200) if sync_limit > 0: action['limit'] = sync_limit except Exception: diff --git a/gmn_freshservice/models/gmn_res_config_settings_v01.py b/gmn_freshservice/models/gmn_res_config_settings_v01.py index 3b36c9f..6bc148b 100644 --- a/gmn_freshservice/models/gmn_res_config_settings_v01.py +++ b/gmn_freshservice/models/gmn_res_config_settings_v01.py @@ -8,24 +8,25 @@ class ResConfigSettings(models.TransientModel): freshservice_url = fields.Char( string='Freshservice URL', - config_parameter='freshservice_ticket_audit.freshservice_url', + config_parameter='gmn_freshservice.freshservice_url', default='https://ptglobalmultimedianetwork.freshservice.com' ) freshservice_api_key = fields.Char( string='Freshservice API Key', - config_parameter='freshservice_ticket_audit.freshservice_api_key' + config_parameter='gmn_freshservice.freshservice_api_key' ) freshservice_sync_limit = fields.Integer( string='Limit Sinkronisasi Tiket', - config_parameter='freshservice_ticket_audit.freshservice_sync_limit', + config_parameter='gmn_freshservice.freshservice_sync_limit', default=200, help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).' ) freshservice_use_mock = fields.Boolean( string='Gunakan Mock Data Engine', - config_parameter='freshservice_ticket_audit.freshservice_use_mock', - # default=True, - help='Jika dicentang, sistem menggunakan simulasi format JSON resmi Freshservice tanpa perlu API Key asli.' + config_parameter='gmn_freshservice.freshservice_use_mock', + default=False, + readonly=True, + help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.' ) def action_test_freshservice_connection(self): diff --git a/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc b/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc index bcd200e..7788d48 100644 Binary files a/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc and b/gmn_freshservice/services/__pycache__/gmn_freshservice_client_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/services/gmn_freshservice_client_v01.py b/gmn_freshservice/services/gmn_freshservice_client_v01.py index 01b6024..0448659 100644 --- a/gmn_freshservice/services/gmn_freshservice_client_v01.py +++ b/gmn_freshservice/services/gmn_freshservice_client_v01.py @@ -31,10 +31,10 @@ class FreshserviceClient: _agent_cache = {} _dept_cache = {} - def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=True): + def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False): self.base_url = (base_url or "").rstrip("/") self.api_key = api_key or "" - self.use_mock = use_mock + self.use_mock = False def get_agent_name(self, agent_id): """Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)""" diff --git a/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml index fec3f3b..55bd039 100644 --- a/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml +++ b/gmn_freshservice/views/gmn_freshservice_ticket_stage_log_views_v01.xml @@ -13,9 +13,10 @@ + decoration-info="responsibility == 'scheduled_meeting'" + decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/> @@ -75,11 +76,11 @@ - + - + diff --git a/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml index 931c8d1..6b7a287 100644 --- a/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml +++ b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml @@ -17,21 +17,10 @@ string="Laporan Bottleneck (Excel)" class="btn-outline-success" icon="fa-file-excel-o"/> - -
-
+
First Response -

- -

- - Oleh: - +

+ Oleh:
-
+
Agent -

- -

+

Waiting on Us
-
+
User -

- -

+

Waiting on Customer
-
+
+
+ Scheduled Meeting +

+ Waiting Together +
+
+
+
+ Third Party +

+ Waiting on Vendor +
+
+
Waktu Selesai -

- -

+

Total Resolution
@@ -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-info="responsibility == 'scheduled_meeting'" + decoration-muted="responsibility in ('third_party','external_activity','resolved_idle')"/> @@ -159,7 +155,11 @@ + + + + @@ -204,6 +204,8 @@ decoration-success="current_responsibility == 'closed'"/> + + diff --git a/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml b/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml index d693eaa..c8d7604 100644 --- a/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml +++ b/gmn_freshservice/views/gmn_res_config_settings_views_v01.xml @@ -7,7 +7,7 @@ - + @@ -21,12 +21,11 @@ - - +
@@ -42,6 +41,6 @@ res.config.settings form main - {'module': 'freshservice_ticket_audit'} + {'module': 'gmn_freshservice'} diff --git a/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc index 575a563..560d3ef 100644 Binary files a/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc and b/gmn_freshservice/wizard/__pycache__/gmn_freshservice_ticket_bottleneck_wizard_v01.cpython-312.pyc differ diff --git a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py index 5306235..6f36007 100644 --- a/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py +++ b/gmn_freshservice/wizard/gmn_freshservice_ticket_bottleneck_wizard_v01.py @@ -207,6 +207,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): 'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', 'align': 'center', 'valign': 'vcenter' }) + scheduled_meeting_fmt = workbook.add_format({ + 'bold': True, 'font_size': 9, 'font_name': 'Calibri', + 'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, + 'border_color': '#85C1E9', 'align': 'center', 'valign': 'vcenter' + }) kpi_box_label = workbook.add_format({ 'font_size': 9, 'font_name': 'Calibri', 'bold': True, 'bg_color': '#EBF5FB', 'font_color': '#2874A6', 'border': 1, 'border_color': '#AED6F1' @@ -255,13 +260,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): summary_html = self._build_single_ticket_sheets( workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, - bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt ) else: summary_html = self._build_ranking_sheets( workbook, tickets, title_fmt, subtitle_fmt, section_fmt, th_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, - bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt ) workbook.close() @@ -309,7 +316,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): def _build_single_ticket_sheets( self, workbook, t, title_fmt, subtitle_fmt, section_fmt, th_fmt, td_fmt, td_wrap_fmt, td_center_fmt, td_num_fmt, td_dec_fmt, td_pct_fmt, td_date_fmt, - bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt=None + bn_badge_fmt, normal_badge_fmt, kpi_box_label, kpi_box_val, status_formats, sla_paused_fmt, + scheduled_meeting_fmt ): """Membuat sheet untuk analisis mendalam bottleneck tiket tunggal.""" ws1 = workbook.add_worksheet('Ringkasan & Bottleneck KPI') @@ -404,12 +412,50 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): 'bg_color': '#FFFFFF', 'font_color': '#145A32', 'border': 1, 'border_color': '#A9DFBF', 'align': 'center', 'valign': 'vcenter' }) + # Warna KPI mengikuti responsibility: internal merah, customer orange, + # scheduled meeting biru, dan third party abu-abu. + kpi_internal_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FADBD8', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA' + }) + kpi_customer_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FCF3CF', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F' + }) + kpi_meeting_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#D6EAF8', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9' + }) + kpi_third_party_label = workbook.add_format({ + 'font_size': 9, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#E5E7E9', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7' + }) + kpi_internal_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FDEDEC', 'font_color': '#922B21', 'border': 1, 'border_color': '#E6B0AA', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_customer_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#FEFDE8', 'font_color': '#7D6608', 'border': 1, 'border_color': '#F9E79F', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_meeting_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#EBF5FB', 'font_color': '#21618C', 'border': 1, 'border_color': '#85C1E9', + 'align': 'center', 'valign': 'vcenter' + }) + kpi_third_party_val = workbook.add_format({ + 'font_size': 11, 'font_name': 'Calibri', 'bold': True, + 'bg_color': '#F2F3F4', 'font_color': '#566573', 'border': 1, 'border_color': '#BDC3C7', + 'align': 'center', 'valign': 'vcenter' + }) ws1.write('B13', 'First Response', kpi_wh_label) ws1.write('C13', 'Total Waktu', kpi_wh_label) - ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_wh_label) - ws1.write('E13', 'Waiting on User (Pengguna)', kpi_wh_label) - ws1.write('F13', 'Total SLA Paused', kpi_wh_label) - ws1.write('G13', 'Resolved / Idle', kpi_wh_label) + ws1.write('D13', 'Waiting on Us (IT/Agent)', kpi_internal_label) + ws1.write('E13', 'Waiting on User (Requester)', kpi_customer_label) + ws1.write('F13', 'Scheduled Meeting', kpi_meeting_label) + ws1.write('G13', 'Third Party', kpi_third_party_label) ws1.write('H13', 'Catatan Rumus', kpi_wh_label) # Hitung jam kerja (UTC+7, 08:30-17:30, break 12:30-13:30, Senin-Jumat) @@ -450,10 +496,10 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): wh_total = sum(stage_wh_map.values()) ws1.write('B14', format_working_duration(wh_fr), kpi_box_val) ws1.write('C14', format_working_duration(wh_total), kpi_box_val) - ws1.write('D14', format_working_duration(wh_agent), kpi_box_val) - ws1.write('E14', format_working_duration(wh_cust), kpi_box_val) - ws1.write('F14', format_working_duration(wh_paused), kpi_box_val) - ws1.write('G14', format_working_duration(wh_resolved_idle), kpi_box_val) + ws1.write('D14', format_working_duration(wh_agent), kpi_internal_val) + ws1.write('E14', format_working_duration(wh_cust), kpi_customer_val) + ws1.write('F14', format_working_duration(wh_meeting), kpi_meeting_val) + ws1.write('G14', format_working_duration(wh_vendor), kpi_third_party_val) ws1.write('H14', 'Senin-Jumat, 08:30-17:30 WIB\nBreak 12:30-13:30 (8 Jam/Hari)\nDB UTC → +7 WIB', kpi_box_val) # REKAPITULASI BEBAN TANGGUNG JAWAB — harus identik dengan total Sheet 2. @@ -466,15 +512,17 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): recap_rows = [ ('Waiting on Us (IT - Pengerjaan Aktif)', 'Waktu penanganan langsung oleh Tim IT (Open / In Progress).', wh_it_direct), ('Waiting on Us (IT - Internal Hold & Discussion)', 'Tiket ditahan/dianalisa internal IT (Aturan 9: default Beban IT).', wh_internal_hold), - ('Waiting on User (Customer)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust), - ('Tunggu Bersama (Scheduled Meeting)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting), + ('Waiting on User (Requester)', 'Menunggu balasan, konfirmasi, atau keputusan dari pemohon / user.', wh_cust), + ('Scheduled Meeting (Waiting Together)', 'Masa tunggu bersama menjelang jadwal meeting / Zoom (SLA Paused).', wh_meeting), ('Waiting on Third Party (Vendor)', 'Eskalasi vendor eksternal atau pengerjaan pihak ketiga (SLA Paused).', wh_vendor), ('SLA Paused Lainnya (Admin / External)', 'Aktivitas jeda administratif sistemik lainnya.', wh_other_paused), ('Resolved / Idle Time (Sebelum Reopen)', 'Tiket telah di-resolve atau idle otomatis, tidak membebani pemohon.', wh_resolved_idle), ('Grand Total Umur Tiket', 'Jumlah seluruh tahapan; harus 100% sama dengan total Sheet 2.', wh_total), ] - for recap_row, (label, description, hours) in enumerate(recap_rows, start=18): - label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (section_fmt if label == 'Grand Total Umur Tiket' else (sla_paused_fmt or td_center_fmt))) + # xlsxwriter memakai indeks baris 0-based: start=17 berarti Excel row 18. + # Ini menghindari satu baris kosong sebelum rekap pertama. + for recap_row, (label, description, hours) in enumerate(recap_rows, start=17): + label_fmt = bn_badge_fmt if label.startswith('Waiting on Us') else (normal_badge_fmt if label.startswith('Waiting on User') else (scheduled_meeting_fmt if label.startswith('Scheduled Meeting') else (section_fmt if label == 'Grand Total Umur Tiket' else (sla_paused_fmt or td_center_fmt)))) ws1.write(recap_row, 1, label, label_fmt) ws1.merge_range(recap_row, 2, recap_row, 5, description, td_fmt) ws1.write(recap_row, 6, hours, td_dec_fmt) @@ -482,18 +530,19 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): ws1.write(recap_row, 8, (hours / wh_total) if wh_total else 0.0, td_pct_fmt) # TABEL RANKING BOTTLENECK PER STAGE - # Rekapitulasi berakhir di baris Excel 26 (grand total), sehingga - # ranking dimulai di baris 28 agar tidak overlap dengan merge C25:F25. - ws1.merge_range('B28:I28', 'RANKING BOTTLENECK PER STAGE (Diurutkan dari Tahapan Paling Lama)', section_fmt) + # Rekap berakhir di Excel row 25. Row 26 adalah pemisah. + # Title di Excel row 27 (index 26), header di Excel row 28 (index 27), + # dan data mulai di Excel row 29 (index 28) secara berurutan tanpa baris kosong. + ws1.merge_range('B27:I27', 'RANKING BOTTLENECK PER STAGE (Diurutkan dari Tahapan Paling Lama)', section_fmt) headers_bn = ['Rank', 'Tahapan / Status', 'Tanggung Jawab', 'Pengguna', 'Hari Kerja Efektif', 'Jam Kerja', '% Kontribusi', 'Status'] for col_idx, h in enumerate(headers_bn, start=1): - ws1.write(29, col_idx, h, th_fmt) + ws1.write(27, col_idx, h, th_fmt) sorted_stages = t.stage_log_ids.sorted(key=lambda s: stage_wh_map.get(s.id, 0.0), reverse=True) total_wh_ticket = max(0.01, sum(stage_wh_map.values()) or 0.01) - row = 30 + row = 28 primary_bn_text = "Tidak terdeteksi delay signifikan." primary_bn_actor = "-" primary_bn_stage = "-" @@ -508,6 +557,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): resp_fmt = bn_badge_fmt elif stage.responsibility == 'customer': resp_label = 'Waiting on User (Requester)' + # Customer selalu orange/kuning, bukan merah. resp_fmt = normal_badge_fmt elif stage.responsibility == 'on_hold': resp_label = 'On Hold (Internal Hold / Waiting on Us)' @@ -519,8 +569,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)' resp_fmt = bn_badge_fmt elif stage.responsibility == 'scheduled_meeting': - resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)' - resp_fmt = sla_paused_fmt or td_center_fmt + resp_label = 'Scheduled Meeting (Waiting Together - SLA is Paused)' + resp_fmt = scheduled_meeting_fmt elif stage.responsibility == 'external_activity': resp_label = 'External Activity (SLA is Paused)' resp_fmt = sla_paused_fmt or td_center_fmt @@ -529,7 +579,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): resp_fmt = sla_paused_fmt or td_center_fmt is_primary = (rank_idx == 1 and stage_wh > 0.0) - ws1.write(row, 1, f"#{rank_idx}", bn_badge_fmt if is_primary else td_center_fmt) + # Nomor ranking utama mengikuti warna tanggung jawab bottleneck. + ws1.write(row, 1, f"#{rank_idx}", resp_fmt if is_primary else td_center_fmt) ws1.write(row, 2, stage.stage_name or '-', td_fmt) ws1.write(row, 3, resp_label, resp_fmt) ws1.write(row, 4, stage.actor_name or '-', td_fmt) @@ -579,7 +630,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): if log.responsibility == 'internal': resp_label = 'Waiting on Us (Internal)' elif log.responsibility == 'customer': - resp_label = 'Waiting on User (Customer)' + resp_label = 'Waiting on User (Requester)' elif log.responsibility == 'on_hold': resp_label = 'On Hold (Internal Hold / Waiting on Us)' elif log.responsibility == 'third_party': @@ -587,9 +638,9 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): elif log.responsibility == 'internal_discussion': resp_label = 'Waiting on Internal Discussion (Internal Hold / Waiting on Us)' elif log.responsibility == 'scheduled_meeting': - resp_label = 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)' + resp_label = 'Scheduled Meeting (Waiting Together - SLA is Paused)' elif log.responsibility == 'external_activity': - resp_label = 'External Activity (SLA is Paused)' + resp_label = 'External Activity (SLA is Paused)' else: resp_label = 'Resolved (Ticket Closed/Idle)' msg_body = self._get_stage_conversation_body(log, t) @@ -615,7 +666,8 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): ws2.write(row2, 2, _to_wib_str(log.start_date), td_center_fmt) ws2.write(row2, 3, _to_wib_str(log.end_date), td_center_fmt) ws2.write(row2, 4, log.stage_name or '-', td_fmt) - ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt))) + resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility == 'customer' else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' else (sla_paused_fmt or td_center_fmt))) + ws2.write(row2, 5, resp_label, resp_fmt) ws2.write(row2, 6, log.actor_type or '-', td_center_fmt) ws2.write(row2, 7, log.actor_name or '-', td_fmt) ws2.write(row2, 8, wd_eff, td_dec_fmt) @@ -832,8 +884,10 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): resp_label = 'On Hold (SLA is Paused)' elif log.responsibility == 'third_party': resp_label = 'Waiting on Third Party (SLA is Paused)' + elif log.responsibility == 'scheduled_meeting': + resp_label = 'Scheduled Meeting (Waiting Together - SLA is Paused)' else: - resp_label = 'Waiting on Internal Discussion (SLA is Paused)' + resp_label = 'Waiting on Internal Discussion (SLA is Paused)' msg_body = self._get_stage_conversation_body(log, t) raw_act_main = log.activity_content or '' if 'Isi Pesan:' in raw_act_main: @@ -856,7 +910,9 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel): ws2.write(row2, 2, _wib(log.start_date), td_center_fmt) ws2.write(row2, 3, _wib(log.end_date), td_center_fmt) ws2.write(row2, 4, log.stage_name or '-', td_fmt) - ws2.write(row2, 5, resp_label, bn_badge_fmt if log.responsibility == 'internal' else (normal_badge_fmt if log.responsibility == 'customer' else (sla_paused_fmt or td_center_fmt))) + resp_fmt = bn_badge_fmt if log.responsibility in ('internal', 'on_hold', 'internal_discussion') else (normal_badge_fmt if log.responsibility == 'customer' else (scheduled_meeting_fmt if log.responsibility == 'scheduled_meeting' and scheduled_meeting_fmt else (sla_paused_fmt or td_center_fmt))) + # Waiting on User memakai normal_badge_fmt orange/kuning. + ws2.write(row2, 5, resp_label, resp_fmt) ws2.write(row2, 6, log.actor_name or '-', td_fmt) ws2.write(row2, 7, calc_log_days, td_num_fmt) ws2.write(row2, 8, log.duration_hours, td_dec_fmt)