[UPD] update logic tiket

This commit is contained in:
Madanil
2026-10-19 16:46:27 +07:00
parent ef5ba7f159
commit 62e545276f
8 changed files with 574 additions and 338 deletions

View File

@@ -255,15 +255,36 @@ class FreshserviceTicketStageLog(models.Model):
stage_name = fields.Char(string='Status / Stage', required=True)
responsibility = fields.Selection([
('internal', 'Internal (Waiting on Us)'),
('customer', 'Customer (Waiting on User)'),
('on_hold', 'On Hold (SLA is Paused)'),
('third_party', 'Waiting on Third Party (SLA is Paused)'),
('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'),
('scheduled_meeting', '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([

View File

@@ -36,7 +36,7 @@ class FreshserviceTicket(models.Model):
requester_name = fields.Char(string='Pemohon (User)', tracking=True)
requester_email = fields.Char(string='Email Pemohon')
responder_name = fields.Char(string='Agen Penanggung Jawab (IT)', tracking=True)
responder_name = fields.Char(string='Agent Penanggung Jawab (IT)', tracking=True)
department_name = fields.Char(string='Departemen')
# ==========================================
@@ -62,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 = []