[UPD] initial commit

This commit is contained in:
Madanil
2026-10-28 13:45:42 +07:00
commit b47e392df3
52 changed files with 5116 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""Package models: semua model Odoo untuk modul Freshservice.
Urutan import penting karena ticket bergantung pada stage_log dan conversation.
"""
from . import gmn_res_config_settings_v01
from . import gmn_freshservice_holiday_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,276 @@
# -*- coding: utf-8 -*-
"""Model Master Data Hari Libur Nasional & Cuti Bersama.
Menyimpan daftar tanggal merah resmi Indonesia dan cuti bersama perusahaan
yang digunakan oleh Working Hours Engine untuk memotong jam kerja secara presisi.
Mendukung:
1. Input manual melalui antarmuka Tree/Form Odoo.
2. Impor massal dari file spreadsheet Excel (.xlsx).
3. Pengunduhan template spreadsheet Excel (.xlsx) dengan contoh data.
4. Pemuatan instan data baku hari libur nasional Indonesia (2024-2026).
"""
import io
import base64
import logging
from datetime import datetime, date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import xlsxwriter
_logger = logging.getLogger(__name__)
# Daftar Baku Hari Libur Nasional & Cuti Bersama Indonesia (2024-2026)
DEFAULT_INDONESIA_HOLIDAYS = [
# 2024
('2024-01-01', 'Tahun Baru 2024 Masehi', 'public', 'SKB 3 Menteri 2024'),
('2024-02-08', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'),
('2024-02-09', 'Cuti Bersama Tahun Baru Imlek 2575 Kongzili', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-02-10', 'Tahun Baru Imlek 2575 Kongzili', 'public', 'SKB 3 Menteri 2024'),
('2024-03-11', 'Hari Suci Nyepi Tahun Baru Saka 1946', 'public', 'SKB 3 Menteri 2024'),
('2024-03-12', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-03-29', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2024'),
('2024-03-31', 'Hari Paskah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-08', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-09', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-10', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-11', 'Hari Raya Idul Fitri 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-04-12', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-04-15', 'Cuti Bersama Hari Raya Idul Fitri 1445 H', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2024'),
('2024-05-09', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2024'),
('2024-05-10', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-05-23', 'Hari Raya Waisak 2568 BE', 'public', 'SKB 3 Menteri 2024'),
('2024-05-24', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2024'),
('2024-06-17', 'Hari Raya Idul Adha 1445 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-06-18', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2024'),
('2024-07-07', 'Tahun Baru Islam 1446 Hijriah', 'public', 'SKB 3 Menteri 2024'),
('2024-08-17', 'Hari Proklamasi Kemerdekaan RI ke-79', 'public', 'SKB 3 Menteri 2024'),
('2024-09-16', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2024'),
('2024-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2024'),
('2024-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2024'),
# 2025
('2025-01-01', 'Tahun Baru 2025 Masehi', 'public', 'SKB 3 Menteri 2025'),
('2025-01-27', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'),
('2025-01-28', 'Cuti Bersama Tahun Baru Imlek 2576 Kongzili', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-01-29', 'Tahun Baru Imlek 2576 Kongzili', 'public', 'SKB 3 Menteri 2025'),
('2025-03-28', 'Cuti Bersama Hari Suci Nyepi', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-03-29', 'Hari Suci Nyepi Tahun Baru Saka 1947', 'public', 'SKB 3 Menteri 2025'),
('2025-03-31', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-04-01', 'Hari Raya Idul Fitri 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-04-02', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-03', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-04', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-07', 'Cuti Bersama Hari Raya Idul Fitri 1446 H', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-04-18', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2025'),
('2025-04-20', 'Hari Paskah', 'public', 'SKB 3 Menteri 2025'),
('2025-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2025'),
('2025-05-12', 'Hari Raya Waisak 2569 BE', 'public', 'SKB 3 Menteri 2025'),
('2025-05-13', 'Cuti Bersama Hari Raya Waisak', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-05-29', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2025'),
('2025-05-30', 'Cuti Bersama Kenaikan Isa Al Masih', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2025'),
('2025-06-06', 'Hari Raya Idul Adha 1446 Hijriah', 'public', 'SKB 3 Menteri 2025'),
('2025-06-09', 'Cuti Bersama Hari Raya Idul Adha', 'joint_leave', 'SKB 3 Menteri 2025'),
('2025-06-27', '1 Muharam Tahun Baru Islam 1447 H', 'public', 'SKB 3 Menteri 2025'),
('2025-08-17', 'Hari Proklamasi Kemerdekaan RI ke-80', 'public', 'SKB 3 Menteri 2025'),
('2025-09-05', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2025'),
('2025-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2025'),
('2025-12-26', 'Cuti Bersama Hari Raya Natal', 'joint_leave', 'SKB 3 Menteri 2025'),
# 2026
('2026-01-01', 'Tahun Baru 2026 Masehi', 'public', 'SKB 3 Menteri 2026'),
('2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'),
('2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'public', 'SKB 3 Menteri 2026'),
('2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'public', 'SKB 3 Menteri 2026'),
('2026-04-03', 'Wafat Isa Al Masih', 'public', 'SKB 3 Menteri 2026'),
('2026-05-01', 'Hari Buruh Internasional', 'public', 'SKB 3 Menteri 2026'),
('2026-05-14', 'Kenaikan Isa Al Masih', 'public', 'SKB 3 Menteri 2026'),
('2026-05-31', 'Hari Raya Waisak 2570 BE', 'public', 'SKB 3 Menteri 2026'),
('2026-06-01', 'Hari Lahir Pancasila', 'public', 'SKB 3 Menteri 2026'),
('2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'public', 'SKB 3 Menteri 2026'),
('2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'public', 'SKB 3 Menteri 2026'),
('2026-08-25', 'Maulid Nabi Muhammad SAW', 'public', 'SKB 3 Menteri 2026'),
('2026-12-25', 'Hari Raya Natal', 'public', 'SKB 3 Menteri 2026'),
]
class FreshserviceHoliday(models.Model):
"""Model Master Data Hari Libur Nasional & Cuti Bersama.
Menyimpan tanggal merah yang dijadikan acuan pemotongan jam kerja oleh modul.
"""
_name = 'freshservice.holiday'
_description = 'Master Data Hari Libur & Cuti Bersama Indonesia'
_order = 'date desc, id desc'
name = fields.Char(string='Nama Hari Libur / Keterangan', required=True, index=True)
date = fields.Date(string='Tanggal Libur', required=True, index=True)
holiday_type = fields.Selection([
('public', 'Hari Libur Nasional'),
('joint_leave', 'Cuti Bersama'),
('company', 'Libur Khusus Perusahaan'),
], string='Jenis Libur', default='public', required=True)
year = fields.Integer(string='Tahun', compute='_compute_year', store=True, index=True)
active = fields.Boolean(string='Aktif', default=True)
notes = fields.Text(string='Catatan Tambahan')
_sql_constraints = [
('date_unique', 'unique(date)', 'Tanggal hari libur ini sudah terdaftar dalam sistem!')
]
@api.depends('date')
def _compute_year(self):
"""Menghitung tahun secara otomatis dari tanggal libur."""
for rec in self:
rec.year = rec.date.year if rec.date else 0
def action_populate_default_holidays(self, *args, **kwargs):
"""Memuat daftar hari libur baku Indonesia (2024-2026) ke dalam database.
Hanya menambahkan data yang belum terdaftar tanpa menimpa data yang sudah ada.
"""
created_count = 0
for date_str, name, h_type, notes in DEFAULT_INDONESIA_HOLIDAYS:
existing = self.search([('date', '=', date_str)], limit=1)
if not existing:
self.create({
'date': date_str,
'name': name,
'holiday_type': h_type,
'notes': notes,
'active': True,
})
created_count += 1
message = _('Berhasil memuat %d hari libur nasional baku ke database.') % created_count if created_count > 0 else _('Seluruh hari libur nasional baku sudah terdaftar.')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Pemuatan Data Baku Selesai'),
'message': message,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
}
}
def action_download_template(self, *args, **kwargs):
"""Membuat dan mengunduh file template Excel (.xlsx) untuk impor hari libur.
Template dilengkapi dengan format kolom yang benar dan contoh data resmi 2026.
"""
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
worksheet = workbook.add_worksheet('Template Hari Libur')
# Formatter Styling Excel
header_banner_fmt = workbook.add_format({
'bold': True, 'font_size': 14, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#1B365D',
'align': 'left', 'valign': 'vcenter'
})
info_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'font_color': '#5D6D7E',
'italic': True, 'valign': 'vcenter'
})
th_fmt = workbook.add_format({
'bold': True, 'font_size': 10, 'font_name': 'Calibri',
'font_color': '#FFFFFF', 'bg_color': '#2E4053',
'align': 'center', 'valign': 'vcenter', 'border': 1, 'border_color': '#BDC3C7'
})
td_center_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
td_date_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'center',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter',
'num_format': 'yyyy-mm-dd'
})
td_left_fmt = workbook.add_format({
'font_size': 9, 'font_name': 'Calibri', 'align': 'left',
'border': 1, 'border_color': '#D5D8DC', 'valign': 'vcenter'
})
# Set Lebar Kolom
worksheet.set_column('A:A', 6)
worksheet.set_column('B:B', 16)
worksheet.set_column('C:C', 40)
worksheet.set_column('D:D', 26)
worksheet.set_column('E:E', 35)
# Header Title Banner
worksheet.merge_range('A1:E1', 'TEMPLATE IMPORT HARI LIBUR NASIONAL & CUTI BERSAMA', header_banner_fmt)
worksheet.set_row(0, 30)
# Petunjuk Pengisian
worksheet.write('A2', 'PETUNJUK PENGISIAN:', info_fmt)
worksheet.write('A3', '1. Kolom Tanggal Libur (*) dan Nama Hari Libur (*) wajib diisi. Format tanggal: YYYY-MM-DD (contoh: 2026-08-17).', info_fmt)
worksheet.write('A4', '2. Kolom Jenis Libur dapat diisi: "Hari Libur Nasional", "Cuti Bersama", atau "Libur Khusus Perusahaan".', info_fmt)
worksheet.write('A5', '3. Anda dapat mengedit, menambah, atau menghapus baris contoh di bawah ini sebelum mengimpor.', info_fmt)
# Table Headers (Baris 7)
headers = ['No', 'Tanggal Libur (*)', 'Nama Hari Libur (*)', 'Jenis Libur', 'Keterangan']
for col_idx, h in enumerate(headers):
worksheet.write(6, col_idx, h, th_fmt)
worksheet.set_row(6, 24)
# Contoh Data (Hari Libur Resmi 2026 sebagai sampel lengkap)
sample_data = [
(1, '2026-01-01', 'Tahun Baru 2026 Masehi', 'Hari Libur Nasional', 'Libur Nasional Resmi'),
(2, '2026-01-16', 'Isra Mikraj Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(3, '2026-02-17', 'Tahun Baru Imlek 2577 Kongzili', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(4, '2026-03-19', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(5, '2026-03-20', 'Hari Raya Idul Fitri 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(6, '2026-03-21', 'Hari Suci Nyepi Tahun Baru Saka 1948', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(7, '2026-04-03', 'Wafat Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(8, '2026-05-01', 'Hari Buruh Internasional', 'Hari Libur Nasional', 'Hari Buruh'),
(9, '2026-05-14', 'Kenaikan Isa Al Masih', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(10, '2026-05-31', 'Hari Raya Waisak 2570 BE', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(11, '2026-06-01', 'Hari Lahir Pancasila', 'Hari Libur Nasional', 'Hari Lahir Pancasila'),
(12, '2026-06-27', 'Hari Raya Idul Adha 1447 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(13, '2026-07-16', 'Tahun Baru Islam 1448 Hijriah', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(14, '2026-08-17', 'Hari Kemerdekaan Republik Indonesia ke-81', 'Hari Libur Nasional', 'Proklamasi Kemerdekaan'),
(15, '2026-08-25', 'Maulid Nabi Muhammad SAW', 'Hari Libur Nasional', 'SKB 3 Menteri'),
(16, '2026-12-25', 'Hari Raya Natal', 'Hari Libur Nasional', 'Hari Natal'),
]
row = 7
for no, dt_str, name, h_type, notes in sample_data:
worksheet.write(row, 0, no, td_center_fmt)
worksheet.write(row, 1, dt_str, td_date_fmt)
worksheet.write(row, 2, name, td_left_fmt)
worksheet.write(row, 3, h_type, td_left_fmt)
worksheet.write(row, 4, notes, td_left_fmt)
row += 1
workbook.close()
output.seek(0)
file_data = output.read()
output.close()
filename = 'Template_Hari_Libur_Indonesia.xlsx'
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': base64.b64encode(file_data),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'public': True,
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'new',
}
def action_open_import_wizard(self, *args, **kwargs):
"""Membuka dialog popup wizard impor hari libur dari file Excel."""
return {
'name': _('Impor Hari Libur dari Excel'),
'type': 'ir.actions.act_window',
'res_model': 'freshservice.holiday.import.wizard',
'view_mode': 'form',
'target': 'new',
'context': dict(self.env.context),
}

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice.
Setiap baris adalah satu pesan, baik dari user maupun dari agen IT.
"""
from odoo import models, fields
class FreshserviceTicketConversation(models.Model):
"""Menyimpan pesan/balasan pada tiket.
Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel.
"""
_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 (Requester)'),
], 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):
"""Tentukan siapa yang kirim pesan:
- 'customer' jika incoming=True (pesan dari user)
- 'agent' jika incoming=False (balasan dari agen IT)
"""
for rec in self:
rec.sender_type = 'customer' if rec.incoming else 'agent'

View File

@@ -0,0 +1,430 @@
# -*- coding: utf-8 -*-
"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi.
Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan.
"""
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.
Prioritas Pengambilan Data:
1. Model Master Data `freshservice.holiday` di database (hasil input manual atau impor Excel).
2. Fallback ke konstanta `_INDONESIA_HOLIDAYS` jika tabel master data di DB masih kosong
atau jika env Odoo tidak tersedia (misal unit test standalone).
3. Menggabungkan dengan cuti dinamis dari `resource.calendar.leaves` jika tersedia.
"""
res = set()
used_db = False
if env and hasattr(env, '__getitem__'):
try:
HolidayModel = env['freshservice.holiday'].sudo()
# Cek apakah ada record hari libur aktif di database
db_records = HolidayModel.search([
('date', '>=', start_date),
('date', '<=', end_date),
('active', '=', True),
])
if db_records:
res.update(rec.date for rec in db_records)
used_db = True
elif HolidayModel.search_count([('active', '=', True)]) > 0:
# Database sudah memiliki data libur, namun tidak ada libur di rentang tanggal ini
used_db = True
except Exception:
pass
if not used_db:
# Fallback ke konstanta _INDONESIA_HOLIDAYS jika DB belum terisi / tanpa koneksi DB
res.update(d for d in _INDONESIA_HOLIDAYS if start_date <= d <= end_date)
if env and hasattr(env, '__getitem__'):
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):
"""Satu baris = satu fase/status di tiket.
Contoh: Open → In Progress → Pending User → Resolved.
Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya,
dan siapa yang bertanggung jawab (Agent/User/Third Party).
"""
_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', 'Agent Active Work'),
('it_hold', 'Agent Hold & Discussion'),
('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('needs_verification', 'Waiting on User Verification'),
('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('third_party', 'Waiting on Third Party'),
('resolved_idle', 'Resolved / Idle'),
# Legacy values are retained for historical audit snapshots.
('on_hold', 'On Hold (Legacy)'),
('internal_discussion', 'Internal Discussion (Legacy)'),
('external_activity', 'External Activity (Legacy)'),
], string='Task Owner', required=True, default='internal', index=True)
effective_status = fields.Char(string='Effective Status', index=True)
classification_basis = fields.Char(string='Classification Basis')
needs_verification = fields.Boolean(string='User Verification Required')
delay_cause = fields.Selection([
('it', 'Agent'), ('user', 'User'), ('none', 'None'),
], string='Delay Cause', default='none')
automatic_responsibility = fields.Selection(related='responsibility', string='Tanggung Jawab Otomatis', readonly=True)
override_responsibility = fields.Selection([
('internal', 'Agent Active Work'), ('it_hold', 'Agent Hold & Discussion'), ('customer', 'Waiting on Requester'),
('user_after_auto_resolve', 'Waiting on User After Auto-Resolve'),
('needs_verification', 'Waiting on User Verification'), ('scheduled_meeting', 'Scheduled Meeting (Shared)'),
('third_party', 'Waiting on Third Party'), ('resolved_idle', 'Resolved / Idle'),
], string='Task Owner Override')
override_source = fields.Char(string='Override Source')
override_reviewer = fields.Char(string='Override Reviewer')
override_at = fields.Datetime(string='Override Date')
actor_name = fields.Char(string='Pelaku / Actor')
actor_type = fields.Selection([
('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):
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
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):
"""Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat)
dan konversi ke hari kerja (1 hari = 8 jam).
"""
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):
"""Hitung durasi dalam hari kalender dan jam total.
Kalau stage masih aktif, hitung sampai waktu sekarang.
"""
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 = "-"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Konfigurasi Settings untuk koneksi Freshservice.
User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo.
"""
from odoo import models, fields, api
from odoo.exceptions import UserError
import requests
class ResConfigSettings(models.TransientModel):
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
Bisa diakses dari menu Settings > Freshservice.
"""
_inherit = 'res.config.settings'
freshservice_url = fields.Char(
string='Freshservice URL',
config_parameter='gmn_freshservice.freshservice_url',
default='https://ptglobalmultimedianetwork.freshservice.com'
)
freshservice_api_key = fields.Char(
string='Freshservice API Key',
config_parameter='gmn_freshservice.freshservice_api_key'
)
freshservice_sync_limit = fields.Integer(
string='Limit Sinkronisasi Tiket',
config_parameter='gmn_freshservice.freshservice_sync_limit',
default=200,
help='Batas jumlah tiket terbaru yang ditarik dari Freshservice saat membuka halaman list tiket (default 200).'
)
freshservice_use_mock = fields.Boolean(
string='Gunakan Mock Data Engine',
config_parameter='gmn_freshservice.freshservice_use_mock',
default=False,
readonly=True,
help='Mode mock dinonaktifkan. Integrasi selalu menggunakan Live Freshservice API.'
)
def action_test_freshservice_connection(self):
"""Tombol 'Test Connection' di halaman Settings.
Coba panggil API Freshservice untuk cek apakah URL dan API Key valid.
"""
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)}")