[ADD] iitial commit gmn fresh service

This commit is contained in:
Madanil
2026-10-19 08:07:51 +07:00
commit ebdc2ee304
38 changed files with 4111 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import gmn_res_config_settings_v01
from . import gmn_freshservice_ticket_stage_log_v01
from . import gmn_freshservice_ticket_conversation_v01
from . import gmn_freshservice_ticket_v01

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class FreshserviceTicketConversation(models.Model):
_name = 'freshservice.ticket.conversation'
_description = 'Percakapan & Balasan Tiket Freshservice'
_order = 'created_at asc, id asc'
ticket_id = fields.Many2one(
'freshservice.ticket',
string='Tiket',
required=True,
ondelete='cascade',
index=True
)
external_id = fields.Char(string='ID Percakapan')
user_name = fields.Char(string='Pengirim')
user_id_external = fields.Char(string='User ID Freshservice')
incoming = fields.Boolean(
string='Pesan Masuk (Dari User)',
help='True jika dikirim oleh Customer/Requester, False jika balasan dari Agen IT.'
)
sender_type = fields.Selection([
('agent', 'IT Support (Agent)'),
('customer', 'User (Customer)'),
], string='Tipe Pengirim', compute='_compute_sender_type', store=True)
body_text = fields.Text(string='Isi Pesan')
created_at = fields.Datetime(string='Waktu Kirim')
def _compute_sender_type(self):
for rec in self:
rec.sender_type = 'customer' if rec.incoming else 'agent'

View File

@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
from datetime import timedelta, datetime, time
from odoo import models, fields, api
# ============================================================
# KONSTANTA JAM KERJA OPERASIONAL (WIB / UTC+7)
# Senin-Jumat, 08:30-17:30, Break 12:30-13:30 (8 jam efektif)
# ============================================================
_WORK_START = time(8, 30) # 08:30
_WORK_END = time(17, 30) # 17:30
_BREAK_START = time(12, 30) # 12:30
_BREAK_END = time(13, 30) # 13:30
_WORK_HOURS_PER_DAY = 8.0 # jam efektif per hari
def _working_seconds_in_day(t_start, t_end):
"""Hitung detik kerja efektif dalam satu hari antara t_start dan t_end (time objects).
Mengecualikan break 12:30-13:30 dan di luar jam kerja 08:30-17:30.
"""
# Clamp ke jam kerja
t_start = max(t_start, _WORK_START)
t_end = min(t_end, _WORK_END)
if t_end <= t_start:
return 0.0
def to_sec(t):
return t.hour * 3600 + t.minute * 60 + t.second
total = to_sec(t_end) - to_sec(t_start)
# Kurangi overlap dengan break 12:30-13:30
break_s = max(t_start, _BREAK_START)
break_e = min(t_end, _BREAK_END)
if break_e > break_s:
total -= (to_sec(break_e) - to_sec(break_s))
return max(0.0, total)
# Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026)
_INDONESIA_HOLIDAYS = {
# 2024
datetime(2024, 1, 1).date(), datetime(2024, 2, 8).date(), datetime(2024, 2, 9).date(),
datetime(2024, 2, 10).date(), datetime(2024, 3, 11).date(), datetime(2024, 3, 12).date(),
datetime(2024, 3, 29).date(), datetime(2024, 3, 31).date(), datetime(2024, 4, 8).date(),
datetime(2024, 4, 9).date(), datetime(2024, 4, 10).date(), datetime(2024, 4, 11).date(),
datetime(2024, 4, 12).date(), datetime(2024, 4, 15).date(), datetime(2024, 5, 1).date(),
datetime(2024, 5, 9).date(), datetime(2024, 5, 10).date(), datetime(2024, 5, 23).date(),
datetime(2024, 5, 24).date(), datetime(2024, 6, 1).date(), datetime(2024, 6, 17).date(),
datetime(2024, 6, 18).date(), datetime(2024, 7, 7).date(), datetime(2024, 8, 17).date(),
datetime(2024, 9, 16).date(), datetime(2024, 12, 25).date(), datetime(2024, 12, 26).date(),
# 2025
datetime(2025, 1, 1).date(), datetime(2025, 1, 27).date(), datetime(2025, 1, 28).date(),
datetime(2025, 1, 29).date(), datetime(2025, 3, 28).date(), datetime(2025, 3, 29).date(),
datetime(2025, 3, 31).date(), datetime(2025, 4, 1).date(), datetime(2025, 4, 2).date(),
datetime(2025, 4, 3).date(), datetime(2025, 4, 4).date(), datetime(2025, 4, 7).date(),
datetime(2025, 4, 18).date(), datetime(2025, 4, 20).date(), datetime(2025, 5, 1).date(),
datetime(2025, 5, 12).date(), datetime(2025, 5, 13).date(), datetime(2025, 5, 29).date(),
datetime(2025, 5, 30).date(), datetime(2025, 6, 1).date(), datetime(2025, 6, 6).date(),
datetime(2025, 6, 9).date(), datetime(2025, 6, 27).date(), datetime(2025, 8, 17).date(),
datetime(2025, 9, 5).date(), datetime(2025, 12, 25).date(), datetime(2025, 12, 26).date(),
# 2026
datetime(2026, 1, 1).date(), datetime(2026, 1, 16).date(), datetime(2026, 2, 17).date(),
datetime(2026, 3, 19).date(), datetime(2026, 3, 20).date(), datetime(2026, 3, 21).date(),
datetime(2026, 4, 3).date(), datetime(2026, 5, 1).date(), datetime(2026, 5, 14).date(),
datetime(2026, 5, 31).date(), datetime(2026, 6, 1).date(), datetime(2026, 6, 27).date(),
datetime(2026, 7, 16).date(), datetime(2026, 8, 17).date(), datetime(2026, 8, 25).date(),
datetime(2026, 12, 25).date(),
}
def _holiday_dates(env, start_date, end_date):
"""Return public leave dates combining Indonesian public holidays with Odoo's calendar leaves."""
res = {d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date}
if not env or not hasattr(env, '__getitem__'):
return res
try:
leaves = env['resource.calendar.leaves'].sudo().search([
('date_from', '<', datetime.combine(end_date + timedelta(days=1), time.min)),
('date_to', '>=', datetime.combine(start_date, time.min)),
])
for leave in leaves:
if leave.date_from:
res.add(fields.Datetime.context_timestamp(env.user, leave.date_from).date())
except Exception:
pass
return res
def compute_working_hours(env, start_dt, end_dt=None):
"""Menghitung jam kerja efektif antara start_dt dan end_dt.
Aturan:
- Jam kerja: 08:30 - 17:30 WIB (UTC+7)
- Break: 12:30 - 13:30 (tidak dihitung)
- Hari kerja: Senin - Jumat (Sabtu & Minggu dilewati)
- Jam di DB diasumsikan UTC → dikonversi ke WIB dengan +7 jam
Returns:
float: total jam kerja efektif (dibulatkan 2 desimal)
"""
if not start_dt:
return 0.0
# Normalisasi ke datetime naive (UTC)
def to_naive_utc(dt):
if isinstance(dt, str):
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f'):
try:
return datetime.strptime(dt, fmt)
except ValueError:
continue
return None
if hasattr(dt, 'tzinfo') and dt.tzinfo is not None:
return dt.replace(tzinfo=None) - dt.utcoffset()
return dt
s_utc = to_naive_utc(start_dt)
e_utc = to_naive_utc(end_dt) if end_dt else datetime.utcnow()
if not s_utc or not e_utc or e_utc <= s_utc:
return 0.0
# Konversi ke WIB (UTC+7)
s_wib = s_utc + timedelta(hours=7)
e_wib = e_utc + timedelta(hours=7)
total_seconds = 0.0
curr = s_wib.date()
end_date = e_wib.date()
holidays = _holiday_dates(env, curr, end_date)
while curr <= end_date:
# Lewati Sabtu, Minggu, dan tanggal leave/libur nasional terdaftar
if curr.weekday() >= 5 or curr in holidays:
curr += timedelta(days=1)
continue
# Tentukan window waktu untuk hari ini
day_start = datetime.combine(curr, _WORK_START)
day_end = datetime.combine(curr, _WORK_END)
# Clamp ke range s_wib - e_wib
seg_start = max(datetime.combine(curr, time(0, 0)), s_wib)
seg_end = min(datetime.combine(curr, time(23, 59, 59)), e_wib)
# Ambil time portion untuk _working_seconds_in_day
t_s = max(seg_start, day_start)
t_e = min(seg_end, day_end)
if t_e > t_s:
total_seconds += _working_seconds_in_day(t_s.time(), t_e.time())
curr += timedelta(days=1)
return round(total_seconds / 3600.0, 2)
def format_working_duration(hours_float, is_active=False):
"""Format jam kerja efektif menjadi string yang mudah dibaca.
Contoh: 12.0 → '12 Jam (1.5 Hari Kerja)'
"""
if not hours_float or hours_float <= 0:
suffix = ' (Aktif)' if is_active else ''
return f'0 Jam{suffix}'
h = round(hours_float, 2)
wd = round(h / _WORK_HOURS_PER_DAY, 2)
# Tampilkan hari kerja: bulatkan ke .5 terdekat untuk keterbacaan
wd_display = round(wd * 2) / 2
suffix = ' (Aktif)' if is_active else ''
return f'{h:.4g} Jam ({wd_display:.4g} Hari Kerja){suffix}'
def compute_calendar_days(env, start_dt, end_dt=None, exclude_weekends=True):
"""
Menghitung selisih hari kerja (Working Days / Business Days) berdasarkan pergantian tanggal.
Secara default mengecualikan hari Sabtu (weekday=5) dan Minggu (weekday=6),
sehingga hari libur akhir pekan tidak terhitung sebagai waiting time / penalti SLA.
- exclude_weekends=True (default):
* Dibuat Jumat, selesai Senin = 1 Hari Kerja (Sabtu & Minggu diskip).
* Dibuat Jumat, selesai Jumat = 0 Hari Kerja.
* Dibuat Sabtu, selesai Minggu = 0 Hari Kerja.
- exclude_weekends=False:
* Menghitung total hari kalender biasa murni.
"""
if not start_dt:
return 0
try:
user_tz = env.context.get('tz') or env.user.tz or 'Asia/Jakarta'
user_record = env.user.with_context(tz=user_tz)
start_date = fields.Datetime.context_timestamp(user_record, start_dt).date()
if end_dt:
end_date = fields.Datetime.context_timestamp(user_record, end_dt).date()
else:
now_dt = fields.Datetime.now()
end_date = fields.Datetime.context_timestamp(user_record, now_dt).date()
if end_date <= start_date:
return 0
if not exclude_weekends:
return (end_date - start_date).days
# Hitung hari kerja dengan melewati Sabtu (5) dan Minggu (6)
work_days = 0
curr = start_date + timedelta(days=1)
while curr <= end_date:
if curr.weekday() < 5: # 0=Senin ... 4=Jumat
work_days += 1
curr += timedelta(days=1)
return work_days
except Exception:
s_date = start_dt.date() if hasattr(start_dt, 'date') else start_dt
e_date = (end_dt or fields.Datetime.now()).date() if hasattr(end_dt or fields.Datetime.now(), 'date') else end_dt
if e_date <= s_date:
return 0
if not exclude_weekends:
return max(0, (e_date - s_date).days)
work_days = 0
curr = s_date + timedelta(days=1)
while curr <= e_date:
if curr.weekday() < 5:
work_days += 1
curr += timedelta(days=1)
return work_days
def format_duration_days(days_int, is_active=False):
"""Memformat durasi dalam bilangan bulat Hari."""
days = max(0, int(days_int or 0))
if is_active:
return f"{days} Hari (Aktif)"
return f"{days} Hari"
def format_duration_human(hours_float):
"""Helper kompatibilitas yang sekarang menampilkan satuan hari."""
if not hours_float or hours_float <= 0:
return "0 Hari"
days = int(round(hours_float / 24.0))
return f"{days} Hari"
class FreshserviceTicketStageLog(models.Model):
_name = 'freshservice.ticket.stage.log'
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
_order = 'start_date asc, id asc'
ticket_id = fields.Many2one(
'freshservice.ticket',
string='Tiket',
required=True,
ondelete='cascade',
index=True
)
stage_name = fields.Char(string='Status / Stage', required=True)
responsibility = fields.Selection([
('internal', 'Internal (Waiting on Us)'),
('customer', 'Customer (Waiting on User)'),
('on_hold', 'On Hold (SLA is Paused)'),
('third_party', 'Waiting on Third Party (SLA is Paused)'),
('internal_discussion', 'Waiting on Internal Discussion (SLA is Paused)'),
('scheduled_meeting', 'Tunggu Bersama (Scheduled Meeting - SLA is Paused)'),
('external_activity', 'External Activity (SLA is Paused)'),
('resolved_idle', 'Resolved (Ticket Closed/Idle)'),
], string='Tanggung Jawab', required=True, default='internal', index=True)
actor_name = fields.Char(string='Pelaku / Actor')
actor_type = fields.Selection([
('Agent', 'IT Support / Agent'),
('Requester', 'User / Customer'),
('System', 'System'),
], string='Tipe Pelaku', default='Agent')
activity_content = fields.Text(string='Deskripsi Aktivitas')
start_date = fields.Datetime(string='Waktu Mulai', required=True)
end_date = fields.Datetime(string='Waktu Selesai')
is_active = fields.Boolean(
string='Sedang Berjalan',
compute='_compute_is_active',
store=True
)
duration_days = fields.Integer(
string='Durasi (Hari)',
compute='_compute_duration',
store=True
)
duration_hours = fields.Float(
string='Durasi (Jam)',
compute='_compute_duration',
store=True,
digits=(12, 2)
)
duration_display = fields.Char(
string='Durasi',
compute='_compute_duration',
store=True
)
working_hours = fields.Float(
string='Jam Kerja',
compute='_compute_working_metrics',
store=True,
digits=(12, 2)
)
working_days_effective = fields.Float(
string='Hari Kerja Efektif',
compute='_compute_working_metrics',
store=True,
digits=(12, 2)
)
working_duration_display = fields.Char(
string='Durasi Jam Kerja',
compute='_compute_working_metrics',
store=True
)
@api.depends('end_date')
def _compute_is_active(self):
for rec in self:
rec.is_active = not bool(rec.end_date)
@api.depends('start_date', 'end_date', 'is_active')
def _compute_working_metrics(self):
for rec in self:
if rec.start_date:
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
rec.working_hours = wh
rec.working_days_effective = round(wh / 8.0, 2)
rec.working_duration_display = format_working_duration(wh, is_active=rec.is_active)
else:
rec.working_hours = 0.0
rec.working_days_effective = 0.0
rec.working_duration_display = "-"
@api.depends('start_date', 'end_date')
def _compute_duration(self):
for rec in self:
if rec.start_date:
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)
rec.duration_days = days
if rec.end_date:
delta = rec.end_date - rec.start_date
hours = max(0.0, delta.total_seconds() / 3600.0)
rec.duration_hours = round(hours, 2)
rec.duration_display = format_duration_days(days)
else:
# Sedang aktif berjalan sampai sekarang
delta = fields.Datetime.now() - rec.start_date
hours = max(0.0, delta.total_seconds() / 3600.0)
rec.duration_hours = round(hours, 2)
rec.duration_display = format_duration_days(days, is_active=True)
else:
rec.duration_days = 0
rec.duration_hours = 0.0
rec.duration_display = "-"

View File

@@ -0,0 +1,965 @@
# -*- coding: utf-8 -*-
import re
import logging
from datetime import datetime, timedelta, timezone
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from ..services.gmn_freshservice_client_v01 import FreshserviceClient
from .gmn_freshservice_ticket_stage_log_v01 import format_duration_human, format_duration_days, compute_calendar_days
_logger = logging.getLogger(__name__)
class FreshserviceTicket(models.Model):
_name = 'freshservice.ticket'
_description = 'Freshservice Ticket with Audit Trail'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'ticket_created_at desc, id desc'
name = fields.Char(string='Subject / Judul Tiket', required=True, tracking=True)
external_ticket_id = fields.Char(string='ID Tiket Freshservice', required=True, index=True, default='129')
description = fields.Text(string='Deskripsi Tiket')
status = fields.Selection([
('open', 'Open'),
('pending', 'Pending User'),
('resolved', 'Resolved'),
('closed', 'Closed'),
], string='Status', default='open', tracking=True, index=True)
priority = fields.Selection([
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('urgent', 'Urgent'),
], string='Prioritas', default='medium', tracking=True)
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)
department_name = fields.Char(string='Departemen')
# ==========================================
# USER & PENUGASAN (Dari Freshservice)
# ==========================================
user_id = fields.Many2one(
'res.users',
string='Petugas Odoo',
tracking=True,
index=True,
help='User internal Odoo yang bertanggung jawab menangani tiket ini.'
)
user_ids = fields.Many2many(
'res.users',
'freshservice_ticket_user_rel',
'ticket_id',
'user_id',
string='Tim Penangan',
tracking=True,
help='Daftar user internal yang turut berkolaborasi dalam tiket ini.'
)
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')
resolved_at = fields.Datetime(string='Waktu Selesai (Resolved)')
is_mock_data = fields.Boolean(string='Menggunakan Data Mock', default=True, readonly=True)
is_detail_synced = fields.Boolean(
string='Detail Lengkap Tersinkron',
default=False,
readonly=True,
copy=False,
help='Menandakan apakah detail riwayat status & percakapan tiket sudah di-fetch dari Freshservice.'
)
last_detail_sync = fields.Datetime(
string='Waktu Terakhir Sync Detail',
readonly=True,
copy=False
)
# ==========================================
# METRIK SLA & BREAKDOWN DURASI (HARI)
# ==========================================
first_responder_name = fields.Char(string='First Responder', tracking=True)
first_response_time_days = fields.Integer(
string='First Response Time (Hari)',
compute='_compute_time_metrics',
store=True
)
first_response_time_hours = fields.Float(
string='First Response Time (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
first_response_time_display = fields.Char(
string='First Response Time',
compute='_compute_time_metrics',
store=True
)
total_agent_time_days = fields.Integer(
string='Total Waktu di Agen (Hari)',
compute='_compute_time_metrics',
store=True
)
total_agent_time_hours = fields.Float(
string='Total Waktu di Agen (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
total_agent_time_display = fields.Char(
string='Agen (Waiting on Us)',
compute='_compute_time_metrics',
store=True
)
total_customer_time_days = fields.Integer(
string='Total Waktu di User (Hari)',
compute='_compute_time_metrics',
store=True
)
total_customer_time_hours = fields.Float(
string='Total Waktu di User (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
total_customer_time_display = fields.Char(
string='User (Waiting on User)',
compute='_compute_time_metrics',
store=True
)
resolution_time_days = fields.Integer(
string='Waktu Penyelesaian (Hari)',
compute='_compute_time_metrics',
store=True
)
resolution_time_hours = fields.Float(
string='Waktu Penyelesaian (Jam)',
compute='_compute_time_metrics',
store=True,
digits=(12, 2)
)
resolution_time_display = fields.Char(
string='Total Waktu Selesai',
compute='_compute_time_metrics',
store=True
)
current_responsibility = fields.Selection([
('internal', 'Waiting on Us (Internal)'),
('customer', 'Waiting on User (Customer)'),
('closed', 'Tiket Selesai'),
], string='Status Tanggung Jawab Saat Ini', compute='_compute_time_metrics', store=True)
# Relasi
stage_log_ids = fields.One2many(
'freshservice.ticket.stage.log',
'ticket_id',
string='Riwayat Audit Trail & Durasi'
)
conversation_ids = fields.One2many(
'freshservice.ticket.conversation',
'ticket_id',
string='Daftar Percakapan & Balasan'
)
@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'
)
def _compute_time_metrics(self):
for rec in self:
# 1. Hitung First Response
if rec.ticket_created_at and rec.first_responded_at:
delta_fr = rec.first_responded_at - rec.ticket_created_at
hours_fr = max(0.0, delta_fr.total_seconds() / 3600.0)
rec.first_response_time_hours = round(hours_fr, 2)
fr_days = compute_calendar_days(self.env, rec.ticket_created_at, rec.first_responded_at)
rec.first_response_time_days = fr_days
rec.first_response_time_display = format_duration_days(fr_days)
else:
rec.first_response_time_hours = 0.0
rec.first_response_time_days = 0
rec.first_response_time_display = "Belum Ada Respon"
# 2. Hitung total SLA berdasarkan jam kerja efektif, bukan durasi kalender.
agent_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'internal')
cust_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'customer')
agent_days = round(agent_hours / 8.0, 2)
cust_days = round(cust_hours / 8.0, 2)
# Resolved/closed dan aktivitas SLA-paused tidak dibebankan ke user/IT.
total_working_hours = sum(l.working_hours for l in rec.stage_log_ids)
resolved_idle_hours = sum(l.working_hours for l in rec.stage_log_ids if l.responsibility == 'resolved_idle')
rec.total_agent_time_hours = round(agent_hours, 2)
rec.total_agent_time_days = round(agent_days)
rec.total_agent_time_display = format_duration_days(round(agent_days)) if (rec.is_detail_synced or rec.stage_log_ids) else "-"
rec.total_customer_time_hours = round(cust_hours, 2)
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 "-"
# 3. Hitung Resolution Time
if rec.ticket_created_at and rec.resolved_at:
delta_res = rec.resolved_at - rec.ticket_created_at
hours_res = max(0.0, delta_res.total_seconds() / 3600.0)
rec.resolution_time_hours = round(hours_res, 2)
res_days = compute_calendar_days(self.env, rec.ticket_created_at, rec.resolved_at)
rec.resolution_time_days = res_days
rec.resolution_time_display = format_duration_days(res_days)
else:
rec.resolution_time_hours = 0.0
rec.resolution_time_days = 0
rec.resolution_time_display = "Masih Berjalan"
# 4. Status Tanggung Jawab Saat Ini
if rec.status in ('resolved', 'closed'):
rec.current_responsibility = 'closed'
elif rec.status == 'pending':
rec.current_responsibility = 'customer'
else:
rec.current_responsibility = 'internal'
def action_open_bottleneck_wizard(self):
"""Membuka Pop-up Wizard Analisis Bottleneck Excel untuk tiket ini."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Laporan Analisis Bottleneck Excel'),
'res_model': 'freshservice.ticket.bottleneck.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_report_type': 'single',
'default_ticket_id': self.id,
'default_external_ticket_id': self.external_ticket_id,
}
}
def action_assign_to_me(self):
"""Menugaskan tiket ini ke user Odoo yang sedang login"""
self.ensure_one()
current_user = self.env.user
vals = {'user_id': current_user.id}
if current_user not in self.user_ids:
vals['user_ids'] = [(4, current_user.id)]
self.write(vals)
self.message_post(
body=_("Tiket ini telah ditugaskan kepada <b>%s</b>.", current_user.name),
message_type='notification',
subtype_xmlid='mail.mt_note'
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Penugasan Berhasil'),
'message': _('Tiket #%s telah ditugaskan kepada Anda (%s).', self.external_ticket_id, current_user.name),
'type': 'success',
'sticky': False,
}
}
def action_sync_from_freshservice(self):
"""
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan.
Mendukung concurrency row-lock untuk keamanan multi-user.
"""
self.ensure_one()
# Concurrency safety: Row-level lock agar proses multi-user tidak bentrok
try:
self.env.cr.execute(
"SELECT id FROM freshservice_ticket WHERE id = %s FOR UPDATE NOWAIT",
[self.id],
log_exceptions=False
)
except Exception:
_logger.info("Tiket #%s sedang diproses oleh sesi lain. Melewati sinkronisasi konkuren.", self.id)
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)
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
payload = client.fetch_ticket_full_data(self.external_ticket_id)
ticket_data = payload.get('ticket', {})
activities_data = payload.get('activities', [])
conversations_data = payload.get('conversations', [])
# 1. Update Header Tiket
stats = ticket_data.get('stats', {})
status_code = ticket_data.get('status')
status_val = FreshserviceClient.STATUS_MAP.get(status_code, 'open')
priority_code = ticket_data.get('priority')
priority_val = FreshserviceClient.PRIORITY_MAP.get(priority_code, 'medium')
req_obj = ticket_data.get('requester') or {}
requester_name = req_obj.get('name') or ticket_data.get('requester_name') or ''
requester_email = req_obj.get('email') or ticket_data.get('requester_email') or ticket_data.get('email') or ''
responder_name = ticket_data.get('responder_name') or ''
department_name = ticket_data.get('department_name') or ''
# Fallback jika responder_name kosong, cari dari actor bertipe agent di activities
if not responder_name and activities_data:
for act in activities_data:
actor = act.get('actor') or {}
if actor.get('is_agent') and actor.get('name'):
responder_name = actor.get('name')
break
vals = {
'name': ticket_data.get('subject') or self.name,
'description': ticket_data.get('description_text') or '',
'status': status_val,
'priority': priority_val,
'requester_name': requester_name,
'requester_email': requester_email,
'responder_name': responder_name,
'department_name': department_name,
'ticket_created_at': FreshserviceClient.parse_datetime(ticket_data.get('created_at')),
'due_by': FreshserviceClient.parse_datetime(ticket_data.get('due_by')),
'first_responded_at': FreshserviceClient.parse_datetime(stats.get('first_responded_at')),
'resolved_at': FreshserviceClient.parse_datetime(stats.get('resolved_at')),
'is_mock_data': use_mock,
'is_detail_synced': True,
'last_detail_sync': fields.Datetime.now(),
}
self.write(vals)
# 2. Rekonstruksi Audit Trail Activities menjadi Stage Logs.
# Jangan hapus snapshot lama sebelum payload terbukti memuat activities.
# Freshservice sesekali mengembalikan respons parsial/kosong; menghapus
# lebih dulu akan membuat seluruh Audit Trail hilang.
# Gunakan parser yang sama untuk key sorting karena beberapa payload/API
# dapat mencampur ISO string dengan datetime Python.
def _audit_datetime(value):
"""Return naive UTC datetime for internal comparisons only."""
if not value:
return False
if isinstance(value, datetime):
if value.tzinfo is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value
if isinstance(value, str):
clean = value.strip().replace('Z', '+00:00')
try:
parsed = datetime.fromisoformat(clean)
except ValueError:
try:
parsed = datetime.strptime(value.strip(), '%Y-%m-%d %H:%M:%S')
except ValueError:
return False
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
return False
sorted_acts = sorted(
activities_data,
key=lambda x: _audit_datetime(x.get('created_at')) or datetime.min,
)
has_activity_payload = bool(sorted_acts)
if not has_activity_payload:
_logger.warning(
'Freshservice tidak mengembalikan activities untuk ticket #%s; '
'Audit Trail lama dipertahankan.', self.external_ticket_id,
)
# Buat pemetaan percakapan berdasarkan waktu (exact ISO dan menit) untuk pencocokan isi pesan balasan/note
conv_by_time = {}
for c in conversations_data:
c_time = c.get('created_at')
if c_time:
conv_by_time[c_time] = c
conv_by_time[c_time[:16]] = c # toleransi pencocokan hingga menit (YYYY-MM-DDTHH:MM)
log_vals_list = []
current_state = 'internal'
meeting_wait_until = False
meeting_start_dt = False
meeting_waiting_for_followup = False
meeting_invitation_pattern = re.compile(
r'join\s+zoom\s+meeting|zoom\.us/j/|meeting\s+id\s*:|passcode\s*:|invitation\s+zoom',
re.IGNORECASE,
)
meeting_time_pattern = re.compile(
r'Time:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}\s+[AP]M)',
re.IGNORECASE,
)
for idx, act in enumerate(sorted_acts):
# datetime objects for comparisons; convert to Odoo strings only at write time.
start_dt_obj = _audit_datetime(act.get('created_at'))
start_dt = start_dt_obj.strftime('%Y-%m-%d %H:%M:%S') if start_dt_obj else False
# Waktu selesai adalah dimulainya aktivitas berikutnya
end_dt_obj = False
if idx + 1 < len(sorted_acts):
end_dt_obj = _audit_datetime(sorted_acts[idx + 1].get('created_at'))
elif self.status in ('resolved', 'closed'):
end_dt_obj = _audit_datetime(self.resolved_at) or start_dt_obj
end_dt = end_dt_obj.strftime('%Y-%m-%d %H:%M:%S') if end_dt_obj else False
# 1. Ekstraksi Data Pelaku (Actor)
# Di Freshservice Live API, actor berupa dict: {"id": ..., "name": "...", "is_agent": True/False}
actor_dict = act.get('actor') if isinstance(act.get('actor'), dict) else {}
actor_name = (actor_dict.get('name') or act.get('actor_name') or '').strip()
actor_id = actor_dict.get('id') if actor_dict.get('id') is not None else act.get('actor_id')
is_agent = actor_dict.get('is_agent', False)
if not actor_name:
if actor_id:
actor_name = str(actor_id)
else:
actor_name = 'System'
# Tentukan actor_type
if actor_id == 0 or any(kw in actor_name.lower() for kw in ('ticket workflow', 'workflow', 'system')):
actor_type = 'System'
elif is_agent:
actor_type = 'Agent'
elif not is_agent and actor_id and actor_id != 0:
actor_type = 'Requester'
elif act.get('actor_type') in ('Agent', 'Requester', 'System'):
actor_type = act.get('actor_type')
elif self.requester_name and actor_name.lower() in self.requester_name.lower():
actor_type = 'Requester'
elif self.responder_name and actor_name.lower() in self.responder_name.lower():
actor_type = 'Agent'
else:
actor_type = 'Agent'
# 2. Ekstraksi Deskripsi Aktivitas (Content + Sub-contents)
content_main = (act.get('content') or '').strip()
sub_contents = act.get('sub_contents') or []
if not isinstance(sub_contents, list):
sub_contents = []
# Cari pesan percakapan/balasan jika aktivitas berkaitan dengan replied atau note
matched_msg = ""
act_created = act.get('created_at') or ''
if any(k in content_main.lower() for k in ['replied', 'reply', 'note']):
matched_conv = conv_by_time.get(act_created) or conv_by_time.get(act_created[:16])
if matched_conv:
raw_body = (matched_conv.get('body_text') or '').strip()
if raw_body:
matched_msg = raw_body
content_lines = []
if content_main:
content_lines.append(content_main)
if matched_msg:
content_lines.append(f"Isi Pesan:\n\"{matched_msg}\"")
for sub in sub_contents:
if sub:
content_lines.append(f"• {str(sub).strip()}")
full_activity_content = "\n".join(content_lines)
# 3. Tentukan stage_name
# Periksa apakah ada perubahan status eksplisit di sub_contents atau content
extracted_status = None
for sub in sub_contents:
if sub and 'set status as' in str(sub).lower():
m = re.search(r'set Status as\s+([^(\n,]+)', str(sub), re.IGNORECASE)
if m:
extracted_status = m.group(1).strip()
break
if not extracted_status and content_main and 'set status as' in content_main.lower():
m = re.search(r'set Status as\s+([^(\n,]+)', content_main, re.IGNORECASE)
if m:
extracted_status = m.group(1).strip()
stage_name = act.get('stage_name')
if not stage_name:
if extracted_status:
stage_name = extracted_status
elif content_main:
c_lower = content_main.lower()
if 'replied' in c_lower:
stage_name = 'Reply / Balasan'
elif 'private note' in c_lower:
stage_name = 'Private Note'
elif 'public note' in c_lower or 'note' in c_lower:
stage_name = 'Note'
elif 'executed' in c_lower and 'workflow' in c_lower:
stage_name = 'Workflow Trigger'
elif len(content_main) <= 50:
stage_name = content_main
else:
stage_name = content_main[:47] + '...'
else:
stage_name = 'Aktivitas'
# 4. Tentukan Tanggung Jawab (Responsibility) dengan Sticky State Machine.
# Hanya perubahan Status eksplisit atau balasan Requester yang boleh mengubah state.
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'))
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')):
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'
responsibility = current_state
# 4a. Smart Detection: undangan Zoom meng-overwrite rentang tunggu hingga follow-up Agent.
is_meeting_invitation = bool(meeting_invitation_pattern.search(full_activity_content or ''))
if is_meeting_invitation:
parsed_meeting_dt = False
time_match = meeting_time_pattern.search(full_activity_content or '')
if time_match:
try:
# Zoom time has no timezone in Freshservice text; it is interpreted as WIB then stored UTC.
parsed_meeting_dt = datetime.strptime(
time_match.group(1).title(), '%b %d, %Y %I:%M %p'
) - timedelta(hours=7)
except ValueError:
_logger.warning('Unable to parse scheduled Zoom time %r for ticket %s', time_match.group(1), self.external_ticket_id)
meeting_start_dt = parsed_meeting_dt
meeting_waiting_for_followup = True
meeting_wait_until = False
if meeting_wait_until and start_dt_obj and start_dt_obj >= meeting_wait_until:
meeting_wait_until = False
# Keep the boundary locally because this activity can close the meeting phase.
interval_meeting_start = meeting_start_dt
is_agent_followup = actor_type == 'Agent' and any(
k in (content_main or '').lower() for k in ('replied', 'reply', 'note')
)
if is_meeting_invitation:
responsibility = 'scheduled_meeting'
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
# 5. Set First Responder jika belum terdeteksi
if not self.first_responder_name and actor_type == 'Agent':
if 'replied' in content_main.lower() or 'reply' in content_main.lower() or not any('replied' in (a.get('content') or '').lower() for a in sorted_acts):
self.first_responder_name = actor_name
log_values = {
'stage_name': stage_name,
'responsibility': responsibility,
'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))
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})
# 3. Update Percakapan. Terapkan perlindungan yang sama untuk respons kosong.
conv_vals_list = []
for conv in conversations_data:
conv_vals_list.append((0, 0, {
'external_id': str(conv.get('id') or ''),
'user_name': conv.get('user_name') or 'User',
'user_id_external': str(conv.get('user_id') or ''),
'incoming': conv.get('incoming', False),
'body_text': conv.get('body_text') or '',
'created_at': FreshserviceClient.parse_datetime(conv.get('created_at')),
}))
if conv_vals_list:
self.conversation_ids.unlink()
self.write({'conversation_ids': conv_vals_list})
elif not conversations_data:
_logger.warning(
'Freshservice tidak mengembalikan conversations untuk ticket #%s; '
'riwayat percakapan lama dipertahankan.', self.external_ticket_id,
)
mode_text = "MOCK ENGINE (Simulasi Format Resmi)" if use_mock else "LIVE FRESHSERVICE API"
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': f'Sinkronisasi Sukses [{mode_text}]',
'message': f'Data Tiket #{self.external_ticket_id} dan seluruh audit trail durasi berhasil dimutakhirkan!',
'type': 'success',
'sticky': False,
}
}
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
# =========================================================================
@api.model
def _auto_sync_recent_tickets(self, limit=None, raise_if_empty=False, tiket_id=None):
"""
Otomatis fetch daftar tiket terbaru (hanya ringkasan dan status) dari Freshservice.
Batas jumlah tiket (limit) dibaca dari konfigurasi Settings (default 200).
Detail lengkap (activities, stage logs, conversations) HANYA di-fetch on-demand saat user
membuka halaman detail (form view) tiket terkait.
Dilengkapi throttling 5 detik agar aman bagi multi-user dan tidak membebani API.
"""
if getattr(self.env.cr, 'readonly', False):
with self.env.registry.cursor(readonly=False) as rw_cr:
rw_env = self.env(cr=rw_cr)
res = rw_env['freshservice.ticket']._auto_sync_recent_tickets(limit=limit, raise_if_empty=raise_if_empty)
rw_cr.commit()
self.invalidate_recordset()
return res
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)
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')
if last_sync:
try:
# Throttling: Jangan panggil ulang jika baru saja di-fetch dalam 5 detik terakhir
if now_ts - float(last_sync) < 5:
return 0
except Exception:
pass
param_obj.set_param('freshservice_ticket_audit.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)
try:
client = FreshserviceClient(base_url=base_url, api_key=api_key, use_mock=use_mock)
tickets_list = client.fetch_recent_tickets(limit=limit, tiket_id=tiket_id)
except Exception as e:
_logger.warning("Auto-fetch Freshservice gagal: %s", e)
if raise_if_empty:
raise UserError(str(e))
return 0
if not tickets_list:
if raise_if_empty:
raise UserError(_("Tidak ada tiket yang ditemukan dari Freshservice. Pastikan URL dan API Key di Settings sudah benar."))
return 0
ext_ids = [str(t.get('id')) for t in tickets_list if t.get('id')]
existing_tickets = {
rec.external_ticket_id: rec
for rec in self.search([('external_ticket_id', 'in', ext_ids)])
}
to_create = []
synced_count = 0
for t in tickets_list:
ext_id = str(t.get('id') or '')
if not ext_id:
continue
stats = t.get('stats', {}) if isinstance(t.get('stats'), dict) else {}
status_code = t.get('status')
status_val = FreshserviceClient.STATUS_MAP.get(status_code, 'open')
priority_code = t.get('priority')
priority_val = FreshserviceClient.PRIORITY_MAP.get(priority_code, 'medium')
created_at_dt = FreshserviceClient.parse_datetime(t.get('created_at'))
due_by_dt = FreshserviceClient.parse_datetime(t.get('due_by'))
fr_dt = FreshserviceClient.parse_datetime(stats.get('first_responded_at'))
res_dt = FreshserviceClient.parse_datetime(stats.get('resolved_at'))
vals = {
'name': t.get('subject') or f"Tiket #{ext_id}",
'status': status_val,
'priority': priority_val,
'ticket_created_at': created_at_dt,
'due_by': due_by_dt,
'first_responded_at': fr_dt,
'resolved_at': res_dt,
'is_mock_data': use_mock,
}
if t.get('responder_name'):
vals['responder_name'] = t.get('responder_name')
if t.get('requester_name'):
vals['requester_name'] = t.get('requester_name')
if t.get('requester_email'):
vals['requester_email'] = t.get('requester_email')
if t.get('department_name'):
vals['department_name'] = t.get('department_name')
if t.get('description_text'):
vals['description'] = t.get('description_text')
if ext_id in existing_tickets:
rec = existing_tickets[ext_id]
write_vals = {}
for k, v in vals.items():
if getattr(rec, k, None) != v:
write_vals[k] = v
if write_vals:
# Jika status tiket di Freshservice berubah, reset is_detail_synced agar saat detail dibuka di-sync ulang
if 'status' in write_vals and rec.is_detail_synced:
write_vals['is_detail_synced'] = False
rec.write(write_vals)
synced_count += 1
else:
vals['external_ticket_id'] = ext_id
vals['is_detail_synced'] = False
to_create.append(vals)
synced_count += 1
if to_create:
self.create(to_create)
return synced_count
@api.model
def _extract_ticket_ids_from_domain(self, domain):
"""Ambil angka ticket ID dari domain pencarian agar bisa fetch on-demand."""
ticket_ids = []
if not domain:
return ticket_ids
def _walk(node):
if isinstance(node, (list, tuple)):
if len(node) == 3 and isinstance(node[0], str):
field_name, operator, value = node
if field_name in ('external_ticket_id', 'name', 'id') and value is not None:
candidates = re.findall(r'\d+', str(value))
for candidate in candidates:
if candidate and candidate not in ticket_ids:
ticket_ids.append(candidate)
return
for child in node:
_walk(child)
_walk(domain)
return ticket_ids
@api.model
def _fetch_and_create_ticket_by_external_id(self, ticket_id):
"""Ambil tiket tertentu dari Freshservice jika belum ada di Odoo."""
if ticket_id in (None, ''):
return self.browse()
clean_id = str(ticket_id).strip().lstrip('#')
if not clean_id or not clean_id.isdigit():
return self.browse()
if getattr(self.env.cr, 'readonly', False):
with self.env.registry.cursor(readonly=False) as rw_cr:
rw_env = self.env(cr=rw_cr)
res = rw_env['freshservice.ticket']._fetch_and_create_ticket_by_external_id(clean_id)
rw_cr.commit()
self.invalidate_recordset()
return res
existing = self.search([('external_ticket_id', '=', clean_id)], limit=1)
if existing:
return existing
try:
ticket = self.create({
'name': f'Tiket Freshservice #{clean_id}',
'external_ticket_id': clean_id,
'is_detail_synced': False,
})
ticket.action_sync_from_freshservice()
return ticket
except Exception as e:
_logger.warning('Gagal fetch on-demand ticket #%s dari Freshservice: %s', clean_id, e)
return self.browse()
@api.model
def action_open_and_fetch_tickets(self, tiket_id = None):
"""
Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket',
lalu langsung membuka tampilan list tiket secara real-time!
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
"""
try:
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
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')
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)
if sync_limit > 0:
action['limit'] = sync_limit
except Exception:
pass
return action
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=100):
if name and isinstance(name, str):
ticket_ids = re.findall(r'\d+', name.strip())
for ticket_id in ticket_ids:
if not self.search_count([('external_ticket_id', '=', ticket_id)]):
self._fetch_and_create_ticket_by_external_id(ticket_id)
return super().name_search(name=name, args=args, operator=operator, limit=limit)
@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
"""
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket.
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
"""
if offset == 0 and not self.env.context.get('skip_auto_fetch'):
try:
self.with_context(skip_auto_fetch=True)._auto_sync_recent_tickets(limit=None, raise_if_empty=False)
except Exception as e:
_logger.warning("Auto fetch di web_search_read: %s", e)
for ticket_id in self._extract_ticket_ids_from_domain(domain):
if not self.search_count([('external_ticket_id', '=', ticket_id)]):
self._fetch_and_create_ticket_by_external_id(ticket_id)
return super().web_search_read(
domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit
)
@api.readonly
def web_read(self, specification: dict[str, dict]) -> list[dict]:
"""
Ketika pengguna membuka form view (detail tiket), jika detail audit trail
belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand.
"""
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'):
# Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
is_detail_view = any(f in specification for f in ('stage_log_ids', 'conversation_ids', 'description'))
if is_detail_view and not self.is_detail_synced:
try:
if getattr(self.env.cr, 'readonly', False):
with self.env.registry.cursor(readonly=False) as rw_cr:
rw_env = self.env(cr=rw_cr)
rw_env['freshservice.ticket'].browse(self.id).with_context(skip_auto_detail_fetch=True).action_sync_from_freshservice()
rw_cr.commit()
self.invalidate_recordset()
else:
self.with_context(skip_auto_detail_fetch=True).action_sync_from_freshservice()
except Exception as e:
_logger.warning("Gagal auto-fetch detail saat membuka form tiket #%s: %s", self.external_ticket_id, e)
return super().web_read(specification)
def action_sync_recent_tickets(self):
"""
Tombol manual 'Tarik Tiket Terbaru Freshservice' di header tabel jika user ingin refresh paksa.
"""
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Sinkronisasi Daftar Tiket Berhasil'),
'message': _('Berhasil memuat %d tiket terbaru dari Freshservice!', synced_count),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
}
}

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import UserError
import requests
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
freshservice_url = fields.Char(
string='Freshservice URL',
config_parameter='freshservice_ticket_audit.freshservice_url',
default='https://ptglobalmultimedianetwork.freshservice.com'
)
freshservice_api_key = fields.Char(
string='Freshservice API Key',
config_parameter='freshservice_ticket_audit.freshservice_api_key'
)
freshservice_sync_limit = fields.Integer(
string='Limit Sinkronisasi Tiket',
config_parameter='freshservice_ticket_audit.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.'
)
def action_test_freshservice_connection(self):
"""Uji konektivitas ke server Freshservice"""
self.ensure_one()
url = (self.freshservice_url or "").rstrip("/")
if not url:
raise UserError("URL Freshservice tidak boleh kosong!")
if self.freshservice_use_mock:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Mode Mock Aktif',
'message': 'Engine simulasi aktif. Anda dapat langsung menarik tiket contoh tanpa API Key!',
'type': 'success',
'sticky': False,
}
}
if not self.freshservice_api_key:
raise UserError("API Key wajib diisi untuk pengujian Live API!")
try:
auth = (self.freshservice_api_key, 'X')
headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
res = requests.get(f"{url}/api/v2/tickets?per_page=1", auth=auth, headers=headers, timeout=10)
if res.status_code == 200:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Koneksi Berhasil!',
'message': f'Berhasil terhubung ke Live Freshservice API ({url}).',
'type': 'success',
'sticky': False,
}
}
else:
raise UserError(f"Gagal terhubung (HTTP {res.status_code}): {res.text}")
except Exception as e:
raise UserError(f"Terjadi kesalahan koneksi: {str(e)}")