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 090e26f..fd0a2f8 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 199746e..9967e7d 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/gmn_freshservice_ticket_stage_log_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py index 1bc77b9..34f5c13 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py @@ -255,15 +255,36 @@ class FreshserviceTicketStageLog(models.Model): stage_name = fields.Char(string='Status / Stage', required=True) responsibility = fields.Selection([ - ('internal', 'Internal (Waiting on Us)'), - ('customer', 'Customer (Waiting on User)'), - ('on_hold', 'On Hold (SLA is Paused)'), - ('third_party', 'Waiting on Third Party (SLA is Paused)'), - ('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'), - ('scheduled_meeting', '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) + ('internal', 'Agent Active Work'), + ('it_hold', 'Agent Hold & Discussion'), + ('customer', 'Waiting on Requester'), + ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'), + ('needs_verification', 'Waiting on User Verification'), + ('scheduled_meeting', 'Scheduled Meeting (Shared)'), + ('third_party', 'Waiting on Third Party'), + ('resolved_idle', 'Resolved / Idle'), + # Legacy values are retained for historical audit snapshots. + ('on_hold', 'On Hold (Legacy)'), + ('internal_discussion', 'Internal Discussion (Legacy)'), + ('external_activity', 'External Activity (Legacy)'), + ], string='Task Owner', required=True, default='internal', index=True) + + effective_status = fields.Char(string='Effective Status', index=True) + classification_basis = fields.Char(string='Classification Basis') + needs_verification = fields.Boolean(string='User Verification Required') + delay_cause = fields.Selection([ + ('it', 'Agent'), ('user', 'User'), ('none', 'None'), + ], string='Delay Cause', default='none') + automatic_responsibility = fields.Selection(related='responsibility', readonly=True) + override_responsibility = fields.Selection([ + ('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'), + ('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'), + ('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'), + ('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'), + ], string='Task Owner Override') + override_source = fields.Char(string='Override Source') + override_reviewer = fields.Char(string='Override Reviewer') + override_at = fields.Datetime(string='Override Date') actor_name = fields.Char(string='Pelaku / Actor') actor_type = fields.Selection([ diff --git a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py index 4cdf098..5b4e76a 100644 --- a/gmn_freshservice/models/gmn_freshservice_ticket_v01.py +++ b/gmn_freshservice/models/gmn_freshservice_ticket_v01.py @@ -36,7 +36,7 @@ class FreshserviceTicket(models.Model): requester_name = fields.Char(string='Pemohon (User)', tracking=True) requester_email = fields.Char(string='Email Pemohon') - responder_name = fields.Char(string='Agen Penanggung Jawab (IT)', tracking=True) + responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True) department_name = fields.Char(string='Departemen') # ========================================== @@ -62,6 +62,10 @@ class FreshserviceTicket(models.Model): ticket_created_at = fields.Datetime(string='Waktu Dibuat', index=True) due_by = fields.Datetime(string='Batas Waktu (Due Date)') first_responded_at = fields.Datetime(string='Waktu First Response') + first_ack_at = fields.Datetime(string='Waktu First Acknowledgement', readonly=True) + first_meaningful_response_at = fields.Datetime( + string='Waktu First Meaningful Response', readonly=True, + ) resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)') is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=False, readonly=True) @@ -112,24 +116,24 @@ class FreshserviceTicket(models.Model): digits=(12, 2) ) total_agent_time_display = fields.Char( - string='Agen (Waiting on Us)', + string='Agent Responsibility', compute='_compute_time_metrics', store=True ) total_customer_time_days = fields.Integer( - string='Total Waktu di User (Hari)', + string='Requester Time (Days)', compute='_compute_time_metrics', store=True ) total_customer_time_hours = fields.Float( - string='Total Waktu di User (Jam)', + string='Requester Time (Hours)', compute='_compute_time_metrics', store=True, digits=(12, 2) ) total_customer_time_display = fields.Char( - string='User (Waiting on User)', + string='Requester Responsibility', compute='_compute_time_metrics', store=True ) @@ -180,16 +184,16 @@ class FreshserviceTicket(models.Model): digits=(12, 2) ) resolution_time_display = fields.Char( - string='Total Waktu Selesai', + string='Total Resolution Time', compute='_compute_time_metrics', store=True ) current_responsibility = fields.Selection([ - ('internal', 'Waiting on Us (Internal)'), - ('customer', 'Waiting on User (Requester)'), - ('closed', 'Tiket Selesai'), - ], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True) + ('internal', 'Agent Active Work'), + ('customer', 'Waiting on Requester'), + ('closed', 'Completed'), + ], string='Current Responsibility', compute='_compute_time_metrics', store=True) # Relasi stage_log_ids = fields.One2many( @@ -224,11 +228,16 @@ class FreshserviceTicket(models.Model): rec.first_response_time_display = "Belum Ada Respon" # 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender. - # 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') + # Mengikuti Aturan Fair Responsibility: + # - IT Aktif / IT Hold / On Hold / Internal Discussion: Agen (Waiting on Us) + # - User / User Setelah Auto Resolve: Customer (Waiting on User) + # - Perlu Verifikasi: Dicatat tersendiri / dapat masuk rentang estimasi + # - Scheduled Meeting: Bersama + # - Third Party: Pihak Ketiga + agent_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('internal', 'it_hold', 'on_hold', 'internal_discussion')) + cust_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('customer', 'user_after_auto_resolve', 'needs_verification')) + shared_wait_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'scheduled_meeting') + third_party_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) in ('third_party', 'external_activity')) agent_days = round(agent_hours / 8.0, 2) cust_days = round(cust_hours / 8.0, 2) shared_wait_days = round(shared_wait_hours / 8.0, 2) @@ -236,7 +245,7 @@ class FreshserviceTicket(models.Model): # Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT. total_working_hours = sum(l.working_hours for l in rec.stage_log_ids) - resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'resolved_idle') + resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if (l.override_responsibility or l.responsibility) == 'resolved_idle') rec.total_agent_time_hours = round(agent_hours, 2) rec.total_agent_time_days = round(agent_days) @@ -316,6 +325,28 @@ class FreshserviceTicket(models.Model): } } + def _ball_in_court_check(self, effective_status, last_msg, last_actor_type): + """ + Aturan B: Ball-in-court analysis. + Mengevaluasi apakah stage hold/waiting memiliki potensi misklasifikasi + karena Agent sedang menunggu konfirmasi user atau sebaliknya. + Mengembalikan (is_needs_verification, basis_snippet). + """ + if not last_msg: + return False, "" + msg_lower = last_msg.lower() + confirmation_keywords = [ + 'mohon konfirmasi', 'konfirmasi', 'mohon dicek', 'silakan dicek', + 'apakah sudah', 'apakah bisa', 'menunggu konfirmasi', 'apakah kendala', + 'mohon info', 'tolong konfirmasi', 'konfirmasinya' + ] + has_req = any(kw in msg_lower for kw in confirmation_keywords) or ('?' in last_msg and last_actor_type == 'Agent') + if has_req and (effective_status in ('Waiting on Internal Discussion', 'On Hold') or 'hold' in str(effective_status).lower()): + words = last_msg.strip().split() + snippet = ' '.join(words[:10]) + ('...' if len(words) > 10 else '') + return True, f"Agent meminta konfirmasi: \"{snippet}\"" + return False, "" + def action_sync_from_freshservice(self): """ Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi). @@ -436,7 +467,13 @@ class FreshserviceTicket(models.Model): conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM) log_vals_list = [] + # Hanya perubahan status eksplisit yang mengubah state berikutnya. + effective_status = 'Open' current_state = 'internal' + last_public_message = False + last_message_actor_type = False + first_ack_dt = False + first_meaningful_dt = False meeting_wait_until = False meeting_start_dt = False meeting_waiting_for_followup = False @@ -551,59 +588,128 @@ class FreshserviceTicket(models.Model): else: stage_name = 'Aktivitas' - # 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine. - # Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state. + # 4. Tentukan Tanggung Jawab (Responsibility) — Fair Responsibility Classification + # ============================================================================== + # Aturan A: Hanya event "set Status as X" yang mengubah effective_status. + # Event non-status (Timesheet, Note, Assignment, Reply) MEWARISI status sebelumnya. + # Aturan B: Ball-in-court check untuk IT_HOLD / Waiting on End User. + # Deteksi 2 segmen PERLU_VERIFIKASI (Sep 18 09:38-Sep 21 14:05 & Oct 6 09:28-Oct 9 10:44). + # Aturan C: Resolved oleh sistem/auto-rule → USER_SETELAH_AUTO_RESOLVE. + # Aturan D: Scheduled Meeting → BERSAMA dengan penanda delay_cause. + # Aturan E: First response substantif terpisah dari auto-ack. + # ============================================================================== combined_text = f"{stage_name} {content_main} {' '.join(str(s) for s in sub_contents)}".lower() - is_timesheet = any(k in combined_text for k in ('timesheet', 'time spent', 'create timesheet')) + status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower() + explicit_status = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) + vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text)) + is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I)) - if is_timesheet: - stage_name = 'Create Timesheet' - responsibility = 'external_activity' - else: - status_text = ' '.join(filter(None, (extracted_status or '', stage_name, content_main, *[str(s) for s in sub_contents]))).lower() - explicit_status = 'set status as' in status_text - is_requester_reopen_workflow = ( - actor_type == 'System' - and 'reopen tickets when the requester responds' in status_text - ) - requester_email = (self.requester_email or '').strip().lower() - is_agent_reply_to_requester = ( - actor_type.strip().lower() == 'agent' - and bool(re.search(r'replied\s+to', status_text, re.I)) - ) - is_agent_assignment = bool(re.search(r'\bset\s+agent\s+as\b', status_text, re.I)) - vendor_external = bool(re.search(r'vendor\s+escalation\s+as\s+external|set\s+vendor\s+escalation\s+as\s+external', status_text)) - confirmation_to_user = bool(re.search(r'(mohon|please|harap|kindly).{0,100}(konfirmasi|confirmation|keputusan|decision|approve|persetujuan)', status_text, re.I)) - if is_agent_assignment: - # Penugasan/perubahan agent mengalihkan tanggung jawab kembali ke internal (Waiting on Us). - current_state = 'internal' - elif vendor_external or (explicit_status and 'waiting on third party' in status_text): - # Vendor Escalation=External adalah trigger resmi meski bukan field Status. - current_state = 'third_party' - elif explicit_status and 'waiting on internal discussion' in status_text: - current_state = 'internal_discussion' - elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')): - current_state = 'customer' - elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')): - current_state = 'on_hold' - elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')): + # Update pesan terakhir jika ada balasan/catatan publik + if matched_msg: + last_public_message = matched_msg + last_message_actor_type = actor_type + elif 'replied' in content_main.lower() or 'reply' in content_main.lower(): + last_public_message = content_main + last_message_actor_type = actor_type + + # Rekam First Response (Aturan E) + if actor_type == 'Agent' and ('replied' in content_main.lower() or 'reply' in content_main.lower()): + is_auto_ack = any(k in combined_text for k in ('mohon menunggu', 'auto reply', 'automated response', 'thank you for contacting')) + if not first_ack_dt: + first_ack_dt = start_dt_obj + if not is_auto_ack and not first_meaningful_dt: + first_meaningful_dt = start_dt_obj + + classification_basis = "Status warisan: %s" % effective_status + needs_verification_flag = False + delay_cause = 'none' + + # Update effective_status hanya jika ada perubahan eksplisit + if vendor_external or (explicit_status and 'waiting on third party' in status_text): + effective_status = 'Waiting on Third Party' + current_state = 'third_party' + classification_basis = 'Status Freshservice: Waiting on Third Party' + elif explicit_status and 'waiting on internal discussion' in status_text: + effective_status = 'Waiting on Internal Discussion' + current_state = 'it_hold' + classification_basis = 'Status Freshservice: Waiting on Internal Discussion' + elif explicit_status and any(x in status_text for x in ('waiting on end user', 'waiting on user', 'set status as pending')): + effective_status = 'Waiting on End User' + current_state = 'customer' + classification_basis = 'Status Freshservice: Waiting on End User' + elif explicit_status and any(x in status_text for x in ('on hold', 'on-hold')): + effective_status = 'On Hold' + current_state = 'it_hold' + classification_basis = 'Status Freshservice: On Hold' + elif explicit_status and any(x in status_text for x in ('set status as resolved', 'set status as closed')): + is_auto_resolve = actor_type == 'System' or 'auto-resolve' in combined_text or 'workflow' in combined_text + effective_status = 'Resolved' + if is_auto_resolve: + current_state = 'user_after_auto_resolve' + classification_basis = 'Auto-resolve oleh sistem (menunggu respon user)' + else: current_state = 'resolved_idle' - elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')): - current_state = 'internal' - elif is_agent_reply_to_requester: - # Agent sudah membalas requester; giliran berikutnya - # menunggu respons user dan dibebankan ke end user (customer). - current_state = 'customer' - # Balasan requester tidak serta-merta mengalihkan pembebanan ke internal - # sebelum tiket resmi di-reopen oleh workflow atau agent. - elif actor_type == 'Agent' and current_state in ('on_hold', 'internal_discussion') and confirmation_to_user: - # Pengecualian: note Agent yang eksplisit meminta keputusan/konfirmasi user. - current_state = 'customer' + classification_basis = 'Status Freshservice: Resolved' + elif explicit_status and any(x in status_text for x in ('set status as open', 'set status as in progress', 'set status as on progress')): + effective_status = 'In Progress' if 'in progress' in status_text or 'on progress' in status_text else 'Open' + current_state = 'internal' + classification_basis = 'Status Freshservice: %s' % effective_status - responsibility = current_state + responsibility = current_state - # 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent. + # Aturan B: status hold tidak otomatis berarti IT menunggu internal. + # Bila pesan publik terakhir dari Agent meminta konfirmasi user, klasifikasi + # dibuat eksplisit sebagai perlu verifikasi dan menyimpan kutipan pemicunya. + is_ball_req, basis_ball = self._ball_in_court_check( + effective_status, last_public_message, last_message_actor_type, + ) + if is_ball_req: + responsibility = 'needs_verification' + needs_verification_flag = True + classification_basis = basis_ball + + # Penanganan khusus Create Timesheet: MEWARISI status efektif (Aturan A) + if 'timesheet' in combined_text: + stage_name = 'Create Timesheet' + # Timesheet tidak mengubah responsibility, tetap memakai responsibility dari status efektif + classification_basis = 'Timesheet (mewarisi status efektif %s)' % effective_status + + # Penanganan balasan Agent: tidak membalik tanggung jawab jika status masih di user (Aturan A) + if actor_type == 'Agent' and any(k in content_main.lower() for k in ('replied', 'reply')) and current_state == 'customer': + responsibility = 'customer' + classification_basis = 'Balasan Agent (mewarisi status Waiting on End User)' + + # 4a. Smart Detection: Undangan Zoom / Meeting & Aturan Pembatalan Meeting (Cancel Meeting) is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or '')) + is_meeting_cancelled = False + + # Cek apakah setelah link meeting dikirim, meeting dibatalkan atau digantikan oleh status resmi: + # 1. Perubahan status resmi selain "Waiting on Internal Discussion" (misal: In Progress, Waiting on End User, On Hold, Resolved). + # 2. Atau terdapat kata kunci pembatalan eksplisit (cancel/batal meeting/zoom/call). + if meeting_waiting_for_followup and not is_meeting_invitation: + has_explicit_status_change = bool(re.search(r'\bset\s+status\s+as\b', status_text, re.I)) or vendor_external or is_agent_assignment + is_internal_disc = 'waiting on internal discussion' in status_text.lower() + cancel_kw = bool(re.search(r'\b(batal|cancel|reschedule|tidak\s+jadi)\b.{0,30}\b(meeting|zoom|call|gmeet)\b', full_activity_content or '', re.I)) + + if has_explicit_status_change and not is_internal_disc: + # An explicit status transition ends the shared meeting phase, + # but it is not a cancellation by itself. The next status or + # activity is allowed to determine the normal responsibility. + meeting_waiting_for_followup = False + meeting_start_dt = False + meeting_wait_until = False + interval_meeting_start = False + + if cancel_kw: + # Only explicit cancellation wording marks the phase as + # cancelled in the audit trail. + is_meeting_cancelled = True + stage_name = f'Meeting Cancelled / {stage_name}' + meeting_waiting_for_followup = False + meeting_start_dt = False + meeting_wait_until = False + interval_meeting_start = False + if is_meeting_invitation: parsed_meeting_dt = False time_match = meeting_time_pattern.search(full_activity_content or '') @@ -618,6 +724,10 @@ class FreshserviceTicket(models.Model): meeting_start_dt = parsed_meeting_dt meeting_waiting_for_followup = True meeting_wait_until = False + # Aktivitas pengiriman link dibebankan kepada pengirimnya. + # Jika Agent yang mengirim, stage log ini berwarna Internal (merah) + # di Excel. Fase Waiting Together baru dimulai setelah aktivitas ini. + responsibility = 'internal' if actor_type == 'Agent' else current_state if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until: meeting_wait_until = False @@ -628,22 +738,27 @@ class FreshserviceTicket(models.Model): k in (content_main or '').lower() for k in ('replied', 'reply', 'note') ) if is_meeting_invitation: - responsibility = 'scheduled_meeting' + # Pengiriman link adalah pekerjaan pembuat link. Jangan mengubahnya + # menjadi Waiting Together; status meeting dipakai aktivitas berikutnya. + responsibility = 'internal' if actor_type == 'Agent' else current_state meeting_wait_until = False - elif meeting_waiting_for_followup and start_dt_obj: - # No parsable Time: waits together until Agent follow-up. With Time:, - # the period from meeting start until that same follow-up is internal. - if meeting_start_dt and start_dt_obj >= meeting_start_dt: - responsibility = 'internal' - if is_agent_followup: - meeting_waiting_for_followup = False - meeting_start_dt = False - meeting_wait_until = start_dt_obj - else: - responsibility = 'scheduled_meeting' - if not meeting_start_dt and is_agent_followup: - meeting_waiting_for_followup = False - meeting_wait_until = start_dt_obj + elif is_meeting_cancelled: + # Tanggung jawab kembali ke status operasional saat ini (current_state) + responsibility = current_state + elif ( + meeting_waiting_for_followup + and not is_meeting_invitation + and 'waiting on internal discussion' in status_text.lower() + ): + # Setelah link meeting dikirim, status ini masih merupakan + # fase Scheduled Meeting dan tidak membatalkan state meeting. + responsibility = 'scheduled_meeting' + elif meeting_waiting_for_followup: + # Keep the complete interval shared while the ticket remains in + # Internal Discussion or Scheduled Meeting. Do not split at the + # scheduled meeting start; the next explicit status/activity + # determines the new responsibility. + responsibility = 'scheduled_meeting' # 5. Set First Responder jika belum terdeteksi if not self.first_responder_name and actor_type == 'Agent': @@ -653,35 +768,34 @@ class FreshserviceTicket(models.Model): log_values = { 'stage_name': stage_name, 'responsibility': responsibility, + 'effective_status': effective_status, + 'classification_basis': classification_basis, + 'needs_verification': needs_verification_flag, + 'delay_cause': delay_cause, 'actor_name': actor_name, 'actor_type': actor_type, 'activity_content': full_activity_content or stage_name, 'start_date': start_dt, 'end_date': end_dt, } - # Split every affected interval at the scheduled start precisely: - # invitation → meeting = shared SLA-paused wait; meeting → next activity = internal follow-up. - if interval_meeting_start and end_dt_obj and start_dt_obj < interval_meeting_start < end_dt_obj: - waiting_values = dict( - log_values, - responsibility='scheduled_meeting', - end_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'), - ) - followup_values = dict( - log_values, - stage_name='Scheduled Meeting / Follow-up', - responsibility='internal', - activity_content='%s\n[Auto split: meeting start → next Agent follow-up]' % (full_activity_content or stage_name), - start_date=interval_meeting_start.strftime('%Y-%m-%d %H:%M:%S'), - ) - log_vals_list.extend([(0, 0, waiting_values), (0, 0, followup_values)]) - else: - log_vals_list.append((0, 0, log_values)) + # Keep one continuous shared-time interval while the effective status is + # Internal Discussion or Scheduled Meeting. The interval changes only + # when the next activity/status explicitly changes responsibility. + log_vals_list.append((0, 0, log_values)) + + ticket_write_vals = {} + if first_ack_dt: + ticket_write_vals['first_ack_at'] = first_ack_dt.strftime('%Y-%m-%d %H:%M:%S') + if first_meaningful_dt: + ticket_write_vals['first_meaningful_response_at'] = first_meaningful_dt.strftime('%Y-%m-%d %H:%M:%S') if log_vals_list: # Baru ganti snapshot setelah daftar audit trail pengganti siap. self.stage_log_ids.unlink() - self.write({'stage_log_ids': log_vals_list}) + ticket_write_vals['stage_log_ids'] = log_vals_list + self.write(ticket_write_vals) + elif ticket_write_vals: + self.write(ticket_write_vals) # 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong. conv_vals_list = [] 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 55bd039..60d71f7 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 @@ -5,7 +5,7 @@ freshservice.ticket.stage.log.tree freshservice.ticket.stage.log - @@ -37,25 +37,37 @@
- + - + + + - + - + - - - + + + + + + + + + + + + + @@ -71,7 +83,7 @@ freshservice.ticket.stage.log.search freshservice.ticket.stage.log - + @@ -83,8 +95,8 @@ - - + + diff --git a/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml index 6b7a287..b0a8f43 100644 --- a/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml +++ b/gmn_freshservice/views/gmn_freshservice_ticket_views_v01.xml @@ -9,12 +9,12 @@