[ADD] iitial commit gmn fresh service
This commit is contained in:
5
gmn_freshservice/models/__init__.py
Normal file
5
gmn_freshservice/models/__init__.py
Normal 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
|
||||
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
||||
363
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal file
363
gmn_freshservice/models/gmn_freshservice_ticket_stage_log_v01.py
Normal 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 = "-"
|
||||
965
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal file
965
gmn_freshservice/models/gmn_freshservice_ticket_v01.py
Normal 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'},
|
||||
}
|
||||
}
|
||||
71
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal file
71
gmn_freshservice/models/gmn_res_config_settings_v01.py
Normal 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)}")
|
||||
Reference in New Issue
Block a user