changes
This commit is contained in:
@@ -6,3 +6,7 @@ from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_correction
|
||||
|
||||
133
fusion_clock/models/clock_activity_log.py
Normal file
133
fusion_clock/models/clock_activity_log.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionClockActivityLog(models.Model):
|
||||
_name = 'fusion.clock.activity.log'
|
||||
_description = 'Clock Activity Log'
|
||||
_order = 'log_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
log_type = fields.Selection(
|
||||
[
|
||||
('clock_in', 'Clock In'),
|
||||
('clock_out', 'Clock Out'),
|
||||
('late_clock_in', 'Late Clock-In'),
|
||||
('early_clock_out', 'Early Clock-Out'),
|
||||
('outside_geofence', 'Outside Geofence'),
|
||||
('auto_clock_out', 'Auto Clock-Out'),
|
||||
('missed_clock_out', 'Missed Clock-Out'),
|
||||
('absent', 'Absent'),
|
||||
('leave_request', 'Leave Request'),
|
||||
('reason_provided', 'Reason Provided'),
|
||||
('overtime', 'Overtime'),
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
],
|
||||
string='Log Type',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
log_date = fields.Datetime(
|
||||
string='Date/Time',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
index=True,
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Location',
|
||||
ondelete='set null',
|
||||
)
|
||||
latitude = fields.Float(string='Latitude', digits=(10, 7))
|
||||
longitude = fields.Float(string='Longitude', digits=(10, 7))
|
||||
distance = fields.Float(
|
||||
string='Distance (m)',
|
||||
digits=(10, 2),
|
||||
help="Distance from location center in meters.",
|
||||
)
|
||||
source = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('portal_fab', 'Portal FAB'),
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('system', 'System (Cron)'),
|
||||
],
|
||||
string='Source',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
attempt_map_url = fields.Char(
|
||||
string='Map Preview',
|
||||
compute='_compute_attempt_map_url',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
LOG_TYPE_LABELS = {
|
||||
'clock_in': 'Clock In',
|
||||
'clock_out': 'Clock Out',
|
||||
'late_clock_in': 'Late Clock-In',
|
||||
'early_clock_out': 'Early Clock-Out',
|
||||
'outside_geofence': 'Outside Geofence',
|
||||
'auto_clock_out': 'Auto Clock-Out',
|
||||
'missed_clock_out': 'Missed Clock-Out',
|
||||
'absent': 'Absent',
|
||||
'leave_request': 'Leave Request',
|
||||
'reason_provided': 'Reason Provided',
|
||||
'overtime': 'Overtime',
|
||||
'correction_request': 'Correction Request',
|
||||
'ip_fallback': 'IP Fallback Used',
|
||||
'streak_milestone': 'Streak Milestone',
|
||||
}
|
||||
|
||||
@api.depends('latitude', 'longitude')
|
||||
def _compute_attempt_map_url(self):
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.google_maps_api_key', ''
|
||||
)
|
||||
for rec in self:
|
||||
if rec.latitude and rec.longitude and api_key:
|
||||
lat, lng = rec.latitude, rec.longitude
|
||||
rec.attempt_map_url = (
|
||||
f"https://maps.googleapis.com/maps/api/staticmap?"
|
||||
f"center={lat},{lng}&zoom=16&size=600x250&maptype=roadmap"
|
||||
f"&markers=color:red%7C{lat},{lng}"
|
||||
f"&key={api_key}"
|
||||
)
|
||||
else:
|
||||
rec.attempt_map_url = False
|
||||
|
||||
@api.depends('employee_id', 'log_type', 'log_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
ltype = self.LOG_TYPE_LABELS.get(rec.log_type, rec.log_type or '')
|
||||
date_str = rec.log_date.strftime('%Y-%m-%d %H:%M') if rec.log_date else ''
|
||||
rec.display_name = f"{emp} - {ltype} ({date_str})"
|
||||
165
fusion_clock/models/clock_correction.py
Normal file
165
fusion_clock/models/clock_correction.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockCorrection(models.Model):
|
||||
_name = 'fusion.clock.correction'
|
||||
_description = 'Timesheet Correction Request'
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance Record',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
original_check_in = fields.Datetime(
|
||||
string='Original Clock-In',
|
||||
related='attendance_id.check_in',
|
||||
)
|
||||
original_check_out = fields.Datetime(
|
||||
string='Original Clock-Out',
|
||||
related='attendance_id.check_out',
|
||||
)
|
||||
requested_check_in = fields.Datetime(
|
||||
string='Corrected Clock-In',
|
||||
)
|
||||
requested_check_out = fields.Datetime(
|
||||
string='Corrected Clock-Out',
|
||||
)
|
||||
reason = fields.Text(
|
||||
string='Reason for Correction',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
],
|
||||
string='Status',
|
||||
default='pending',
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
reviewed_by = fields.Many2one(
|
||||
'res.users',
|
||||
string='Reviewed By',
|
||||
)
|
||||
reviewed_date = fields.Datetime(string='Reviewed Date')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'attendance_id', 'state')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = rec.attendance_id.check_in.strftime('%Y-%m-%d') if rec.attendance_id and rec.attendance_id.check_in else ''
|
||||
rec.display_name = f"{emp} - Correction ({date_str}) [{rec.state}]"
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
rec._notify_office_user()
|
||||
rec._create_activity_log('pending')
|
||||
return records
|
||||
|
||||
def action_approve(self):
|
||||
"""Approve the correction and update the attendance record."""
|
||||
self.ensure_one()
|
||||
if self.state != 'pending':
|
||||
raise UserError(_("Only pending corrections can be approved."))
|
||||
|
||||
vals = {}
|
||||
if self.requested_check_in:
|
||||
vals['check_in'] = self.requested_check_in
|
||||
if self.requested_check_out:
|
||||
vals['check_out'] = self.requested_check_out
|
||||
|
||||
if vals:
|
||||
self.attendance_id.sudo().write(vals)
|
||||
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
'reviewed_by': self.env.uid,
|
||||
'reviewed_date': fields.Datetime.now(),
|
||||
})
|
||||
self._create_activity_log('approved')
|
||||
|
||||
def action_reject(self):
|
||||
"""Reject the correction request."""
|
||||
self.ensure_one()
|
||||
if self.state != 'pending':
|
||||
raise UserError(_("Only pending corrections can be rejected."))
|
||||
|
||||
self.write({
|
||||
'state': 'rejected',
|
||||
'reviewed_by': self.env.uid,
|
||||
'reviewed_date': fields.Datetime.now(),
|
||||
})
|
||||
self._create_activity_log('rejected')
|
||||
|
||||
def _notify_office_user(self):
|
||||
"""Schedule a mail.activity for the office user."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
date_str = self.attendance_id.check_in.strftime('%Y-%m-%d') if self.attendance_id.check_in else 'unknown'
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': f"Timesheet Correction: {self.employee_id.name} ({date_str})",
|
||||
'note': f"Reason: {self.reason}",
|
||||
'user_id': office_user.id,
|
||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.correction'),
|
||||
'res_id': self.id,
|
||||
'date_deadline': fields.Date.today(),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create correction activity: %s", e)
|
||||
|
||||
def _create_activity_log(self, action):
|
||||
"""Log the correction event."""
|
||||
try:
|
||||
desc = f"Correction {action} for attendance on "
|
||||
if self.attendance_id.check_in:
|
||||
desc += self.attendance_id.check_in.strftime('%Y-%m-%d')
|
||||
desc += f": {self.reason}"
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'correction_request',
|
||||
'description': desc,
|
||||
'attendance_id': self.attendance_id.id,
|
||||
'source': 'system',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create correction log: %s", e)
|
||||
113
fusion_clock/models/clock_leave_request.py
Normal file
113
fusion_clock/models/clock_leave_request.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockLeaveRequest(models.Model):
|
||||
_name = 'fusion.clock.leave.request'
|
||||
_description = 'Clock Leave Request'
|
||||
_order = 'leave_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
leave_date = fields.Date(
|
||||
string='Leave Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
reason = fields.Text(
|
||||
string='Reason',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('auto_approved', 'Auto-Approved'),
|
||||
('reviewed', 'Reviewed'),
|
||||
],
|
||||
string='Status',
|
||||
default='auto_approved',
|
||||
tracking=True,
|
||||
)
|
||||
created_from = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('backend', 'Backend'),
|
||||
],
|
||||
string='Created From',
|
||||
default='portal',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'leave_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = str(rec.leave_date) if rec.leave_date else ''
|
||||
rec.display_name = f"{emp} - Leave ({date_str})"
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
rec._notify_office_user()
|
||||
rec._create_activity_log()
|
||||
return records
|
||||
|
||||
def _notify_office_user(self):
|
||||
"""Schedule a mail.activity for the office user."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
|
||||
'note': f"Reason: {self.reason}",
|
||||
'user_id': office_user.id,
|
||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
|
||||
'res_id': self.id,
|
||||
'date_deadline': self.leave_date,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create leave activity: %s", e)
|
||||
|
||||
def _create_activity_log(self):
|
||||
"""Log the leave request in the activity log."""
|
||||
try:
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'leave_request',
|
||||
'description': f"Leave requested for {self.leave_date}: {self.reason}",
|
||||
'source': 'portal' if self.created_from == 'portal' else 'system',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create leave activity log: %s", e)
|
||||
|
||||
def action_mark_reviewed(self):
|
||||
"""Mark the leave request as reviewed by the office user."""
|
||||
self.write({'state': 'reviewed'})
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
@@ -54,6 +55,19 @@ class FusionClockLocation(models.Model):
|
||||
default=lambda self: self.env.user.tz or 'UTC',
|
||||
)
|
||||
|
||||
# IP whitelist fallback
|
||||
ip_whitelist = fields.Text(
|
||||
string='IP Whitelist',
|
||||
help="One IP address or CIDR per line. Used as fallback when GPS is unavailable.",
|
||||
)
|
||||
|
||||
# Photo verification
|
||||
require_photo = fields.Boolean(
|
||||
string='Require Photo on Clock-In',
|
||||
default=False,
|
||||
help="If enabled, employees must take a selfie when clocking in at this location.",
|
||||
)
|
||||
|
||||
# Computed
|
||||
attendance_count = fields.Integer(
|
||||
string='Total Attendances',
|
||||
@@ -89,6 +103,28 @@ class FusionClockLocation(models.Model):
|
||||
('x_fclk_location_id', '=', rec.id),
|
||||
])
|
||||
|
||||
def check_ip_whitelist(self, client_ip):
|
||||
"""Check if a client IP matches this location's whitelist.
|
||||
Returns True if matched, False otherwise.
|
||||
"""
|
||||
if not self.ip_whitelist or not client_ip:
|
||||
return False
|
||||
try:
|
||||
client = ipaddress.ip_address(client_ip)
|
||||
for line in self.ip_whitelist.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(line, strict=False)
|
||||
if client in network:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def action_geocode_address(self):
|
||||
"""Geocode the address to get lat/lng using Google Geocoding API.
|
||||
Falls back to Nominatim (OpenStreetMap) if Google fails.
|
||||
@@ -97,7 +133,6 @@ class FusionClockLocation(models.Model):
|
||||
if not self.address:
|
||||
raise UserError(_("Please enter an address first."))
|
||||
|
||||
# Try Google first
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
|
||||
if api_key:
|
||||
try:
|
||||
@@ -126,13 +161,12 @@ class FusionClockLocation(models.Model):
|
||||
},
|
||||
}
|
||||
elif data.get('status') == 'REQUEST_DENIED':
|
||||
_logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.")
|
||||
_logger.warning("Google Geocoding API denied. Falling back to Nominatim.")
|
||||
else:
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status'))
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim.", data.get('status'))
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e)
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim.", e)
|
||||
|
||||
# Fallback: Nominatim (OpenStreetMap) - free, no API key needed
|
||||
try:
|
||||
url = 'https://nominatim.openstreetmap.org/search'
|
||||
params = {
|
||||
@@ -165,7 +199,7 @@ class FusionClockLocation(models.Model):
|
||||
},
|
||||
}
|
||||
else:
|
||||
raise UserError(_("Could not geocode address. No results found. Try a more specific address."))
|
||||
raise UserError(_("Could not geocode address. No results found."))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Network error during geocoding: %s") % str(e))
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ class FusionClockPenalty(models.Model):
|
||||
compute='_compute_difference',
|
||||
store=True,
|
||||
)
|
||||
penalty_minutes = fields.Float(
|
||||
string='Deducted (min)',
|
||||
default=0.0,
|
||||
help="Minutes deducted from worked hours as penalty.",
|
||||
)
|
||||
date = fields.Date(string='Date', required=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
|
||||
@@ -182,6 +182,84 @@ class FusionClockReport(models.Model):
|
||||
else:
|
||||
_logger.warning("Fusion Clock: Mail template not found for report %s", self.id)
|
||||
|
||||
def action_export_csv(self):
|
||||
"""Export the report data as a CSV file for payroll."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
self.ensure_one()
|
||||
if not self.attendance_ids:
|
||||
self._collect_attendance_records()
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
mapping_raw = ICP.get_param('fusion_clock.csv_column_mapping', '')
|
||||
import json as json_mod
|
||||
try:
|
||||
col_map = json_mod.loads(mapping_raw) if mapping_raw else {}
|
||||
except Exception:
|
||||
col_map = {}
|
||||
|
||||
default_cols = {
|
||||
'employee': 'Employee',
|
||||
'date': 'Date',
|
||||
'clock_in': 'Clock In',
|
||||
'clock_out': 'Clock Out',
|
||||
'worked_hours': 'Worked Hours',
|
||||
'net_hours': 'Net Hours',
|
||||
'break_min': 'Break (min)',
|
||||
'overtime': 'Overtime (h)',
|
||||
'penalties': 'Penalties',
|
||||
'location': 'Location',
|
||||
}
|
||||
for k in default_cols:
|
||||
if k in col_map:
|
||||
default_cols[k] = col_map[k]
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(list(default_cols.values()))
|
||||
|
||||
for att in self.attendance_ids.sorted(key=lambda a: a.check_in):
|
||||
date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else ''
|
||||
in_str = att.check_in.strftime('%H:%M') if att.check_in else ''
|
||||
out_str = att.check_out.strftime('%H:%M') if att.check_out else ''
|
||||
penalties = self.env['fusion.clock.penalty'].search_count([
|
||||
('attendance_id', '=', att.id),
|
||||
])
|
||||
writer.writerow([
|
||||
att.employee_id.name or '',
|
||||
date_str,
|
||||
in_str,
|
||||
out_str,
|
||||
round(att.worked_hours or 0, 2),
|
||||
round(att.x_fclk_net_hours or 0, 2),
|
||||
round(att.x_fclk_break_minutes or 0, 0),
|
||||
round(att.x_fclk_overtime_hours or 0, 2),
|
||||
penalties,
|
||||
att.x_fclk_location_id.name or '',
|
||||
])
|
||||
|
||||
csv_data = output.getvalue().encode('utf-8')
|
||||
output.close()
|
||||
|
||||
filename = f"clock_export_{self.date_start}_{self.date_end}"
|
||||
if self.employee_id:
|
||||
filename += f"_{self.employee_id.name.replace(' ', '_')}"
|
||||
filename += ".csv"
|
||||
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(csv_data),
|
||||
'mimetype': 'text/csv',
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/web/content/{attachment.id}/{filename}?download=true',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_generate_period_reports(self):
|
||||
"""Cron: Generate reports when a pay period ends."""
|
||||
|
||||
63
fusion_clock/models/clock_shift.py
Normal file
63
fusion_clock/models/clock_shift.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionClockShift(models.Model):
|
||||
_name = 'fusion.clock.shift'
|
||||
_description = 'Clock Shift Schedule'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Shift Name',
|
||||
required=True,
|
||||
help="E.g. 'Morning Shift', 'Evening Shift'.",
|
||||
)
|
||||
start_time = fields.Float(
|
||||
string='Start Time',
|
||||
required=True,
|
||||
default=9.0,
|
||||
help="Shift start in 24h float (e.g. 7.0 = 7:00 AM).",
|
||||
)
|
||||
end_time = fields.Float(
|
||||
string='End Time',
|
||||
required=True,
|
||||
default=17.0,
|
||||
help="Shift end in 24h float (e.g. 15.0 = 3:00 PM).",
|
||||
)
|
||||
break_minutes = fields.Float(
|
||||
string='Break Duration (min)',
|
||||
default=30.0,
|
||||
help="Unpaid break duration in minutes for this shift.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
color = fields.Char(string='Color', default='#3B82F6')
|
||||
employee_ids = fields.One2many(
|
||||
'hr.employee',
|
||||
'x_fclk_shift_id',
|
||||
string='Assigned Employees',
|
||||
)
|
||||
employee_count = fields.Integer(
|
||||
string='Employees',
|
||||
compute='_compute_employee_count',
|
||||
)
|
||||
|
||||
def _compute_employee_count(self):
|
||||
for rec in self:
|
||||
rec.employee_count = len(rec.employee_ids)
|
||||
|
||||
@property
|
||||
def scheduled_hours(self):
|
||||
"""Return the scheduled work hours for this shift (excluding break)."""
|
||||
raw = self.end_time - self.start_time
|
||||
return max(raw - (self.break_minutes / 60.0), 0.0)
|
||||
@@ -21,12 +21,15 @@ class HrAttendance(models.Model):
|
||||
x_fclk_clock_source = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('portal_fab', 'Portal FAB'),
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('manual', 'Manual'),
|
||||
('auto', 'Auto Clock-Out'),
|
||||
],
|
||||
string='Clock Source',
|
||||
tracking=True,
|
||||
help="How this attendance was recorded.",
|
||||
)
|
||||
x_fclk_in_distance = fields.Float(
|
||||
@@ -42,12 +45,14 @@ class HrAttendance(models.Model):
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
tracking=True,
|
||||
help="Break duration in minutes to deduct from worked hours.",
|
||||
)
|
||||
x_fclk_net_hours = fields.Float(
|
||||
string='Net Hours',
|
||||
compute='_compute_net_hours',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Worked hours minus break deduction.",
|
||||
)
|
||||
x_fclk_penalty_ids = fields.One2many(
|
||||
@@ -66,6 +71,26 @@ class HrAttendance(models.Model):
|
||||
help="Whether the grace period was consumed before auto clock-out.",
|
||||
)
|
||||
|
||||
# Overtime
|
||||
x_fclk_overtime_hours = fields.Float(
|
||||
string='Overtime (h)',
|
||||
compute='_compute_overtime_hours',
|
||||
store=True,
|
||||
help="Hours beyond the scheduled shift for this day.",
|
||||
)
|
||||
x_fclk_is_overtime = fields.Boolean(
|
||||
string='Has Overtime',
|
||||
compute='_compute_overtime_hours',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# Photo verification
|
||||
x_fclk_checkin_photo = fields.Binary(
|
||||
string='Check-In Photo',
|
||||
attachment=True,
|
||||
help="Selfie captured at clock-in for verification.",
|
||||
)
|
||||
|
||||
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
||||
def _compute_net_hours(self):
|
||||
for att in self:
|
||||
@@ -73,51 +98,61 @@ class HrAttendance(models.Model):
|
||||
raw = att.worked_hours or 0.0
|
||||
att.x_fclk_net_hours = max(raw - break_hours, 0.0)
|
||||
|
||||
@api.depends('x_fclk_net_hours')
|
||||
def _compute_overtime_hours(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
enable_ot = ICP.get_param('fusion_clock.enable_overtime', 'True') == 'True'
|
||||
daily_threshold = float(ICP.get_param('fusion_clock.daily_overtime_threshold', '8.0'))
|
||||
|
||||
for att in self:
|
||||
if not enable_ot or not att.check_out:
|
||||
att.x_fclk_overtime_hours = 0.0
|
||||
att.x_fclk_is_overtime = False
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
|
||||
net = att.x_fclk_net_hours or 0.0
|
||||
|
||||
if net > scheduled_hours:
|
||||
att.x_fclk_overtime_hours = round(net - scheduled_hours, 2)
|
||||
att.x_fclk_is_overtime = True
|
||||
else:
|
||||
att.x_fclk_overtime_hours = 0.0
|
||||
att.x_fclk_is_overtime = False
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_auto_clock_out(self):
|
||||
"""Cron job: auto clock-out employees after shift + grace period.
|
||||
|
||||
Runs every 15 minutes. Finds open attendances that have exceeded
|
||||
the maximum shift length plus grace period, and closes them.
|
||||
"""
|
||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||
return
|
||||
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
||||
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
|
||||
clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
# Find all open attendances (no check_out)
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
for att in open_attendances:
|
||||
check_in = att.check_in
|
||||
if not check_in:
|
||||
continue
|
||||
|
||||
# Calculate the scheduled end + grace for this attendance
|
||||
check_in_date = check_in.date()
|
||||
out_h = int(clock_out_hour)
|
||||
out_m = int((clock_out_hour - out_h) * 60)
|
||||
scheduled_end = datetime.combine(
|
||||
check_in_date,
|
||||
datetime.min.time().replace(hour=out_h, minute=out_m),
|
||||
)
|
||||
deadline = scheduled_end + timedelta(minutes=grace_min)
|
||||
employee = att.employee_id
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date())
|
||||
|
||||
# Also check max shift safety net
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
|
||||
# Use the earlier of the two deadlines
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
# Auto clock-out at the deadline time (not now)
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
try:
|
||||
att.sudo().write({
|
||||
@@ -128,19 +163,42 @@ class HrAttendance(models.Model):
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
employee = att.employee_id
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
# Post chatter message
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Log to activity log
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. "
|
||||
f"Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
'attendance_id': att.id,
|
||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Set pending reason
|
||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Notify office user
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Auto Clock-Out: {employee.name}",
|
||||
f"{employee.name} was auto-clocked out at {clock_out_time.strftime('%H:%M')}. "
|
||||
f"Please review and correct if needed.",
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
@@ -150,3 +208,242 @@ class HrAttendance(models.Model):
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_check_absences(self):
|
||||
"""Cron job: check for absent employees (no attendance on workday)."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3'))
|
||||
|
||||
yesterday = fields.Date.today() - timedelta(days=1)
|
||||
|
||||
# Skip weekends
|
||||
if yesterday.weekday() >= 5:
|
||||
return
|
||||
|
||||
# Skip public holidays
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', datetime.combine(yesterday, datetime.max.time())),
|
||||
('date_to', '>=', datetime.combine(yesterday, datetime.min.time())),
|
||||
])
|
||||
if holidays:
|
||||
return
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
# Check for attendance yesterday
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(yesterday, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(yesterday + timedelta(days=1), datetime.min.time())),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
# Check for approved leave
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
|
||||
# Mark absent
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': datetime.combine(yesterday, datetime.min.time().replace(hour=9)),
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Check monthly threshold
|
||||
month_start = yesterday.replace(day=1)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_employee_reminders(self):
|
||||
"""Cron job: send clock-in/out reminders to employees."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||
return
|
||||
|
||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
||||
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = fields.Date.today()
|
||||
|
||||
# Skip weekends
|
||||
if today.weekday() >= 5:
|
||||
return
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
for emp in employees:
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(today, datetime.min.time())),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {scheduled_in.strftime('%I:%M %p')}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
# Clock-out reminder
|
||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_weekly_summary(self):
|
||||
"""Cron job: send weekly summary email to employees (Monday 8 AM)."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True':
|
||||
return
|
||||
|
||||
today = fields.Date.today()
|
||||
if today.weekday() != 0:
|
||||
return
|
||||
|
||||
week_start = today - timedelta(days=7)
|
||||
week_end = today - timedelta(days=1)
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
template = self.env.ref('fusion_clock.mail_template_weekly_summary', raise_if_not_found=False)
|
||||
|
||||
for emp in employees:
|
||||
if not emp.work_email:
|
||||
continue
|
||||
|
||||
atts = self.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
|
||||
total_net = sum(a.x_fclk_net_hours or 0 for a in atts)
|
||||
total_ot = sum(a.x_fclk_overtime_hours or 0 for a in atts)
|
||||
penalties = self.env['fusion.clock.penalty'].sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('date', '>=', week_start),
|
||||
('date', '<=', week_end),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
absences = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('log_date', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
|
||||
])
|
||||
|
||||
if template:
|
||||
try:
|
||||
template.with_context(
|
||||
week_start=week_start,
|
||||
week_end=week_end,
|
||||
total_hours=round(total_net, 1),
|
||||
overtime_hours=round(total_ot, 1),
|
||||
penalty_count=penalties,
|
||||
absence_count=absences,
|
||||
streak=emp.x_fclk_ontime_streak,
|
||||
).send_mail(emp.id, force_send=False)
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to send weekly summary to %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _fclk_notify_office(self, office_user_id, summary, note, res_model, res_id):
|
||||
"""Create a mail.activity for the office user."""
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': summary,
|
||||
'note': note,
|
||||
'user_id': office_user_id,
|
||||
'res_model_id': self.env['ir.model']._get_id(res_model),
|
||||
'res_id': res_id,
|
||||
'date_deadline': fields.Date.today(),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create office activity: %s", e)
|
||||
|
||||
@api.model
|
||||
def _fclk_send_employee_reminder(self, employee, subject, body):
|
||||
"""Send a notification to an employee via internal note."""
|
||||
try:
|
||||
if employee.user_id:
|
||||
employee.user_id.sudo().notify_info(
|
||||
message=body,
|
||||
title=subject,
|
||||
sticky=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if employee.work_email:
|
||||
mail_values = {
|
||||
'subject': f"Fusion Clock: {subject}",
|
||||
'body_html': f"<p>{body}</p>",
|
||||
'email_to': employee.work_email,
|
||||
'auto_delete': True,
|
||||
}
|
||||
self.env['mail.mail'].sudo().create(mail_values).send()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to send reminder to %s: %s", employee.name, e)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
@@ -21,16 +22,167 @@ class HrEmployee(models.Model):
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Custom Break (min)',
|
||||
default=0.0,
|
||||
help="Override default break duration for this employee. 0 = use company default.",
|
||||
help="Override default break duration for this employee. 0 = use shift or company default.",
|
||||
)
|
||||
|
||||
# Shift scheduling
|
||||
x_fclk_shift_id = fields.Many2one(
|
||||
'fusion.clock.shift',
|
||||
string='Work Shift',
|
||||
help="Assigned shift schedule. Leave empty to use global defaults.",
|
||||
)
|
||||
|
||||
# Pending reason enforcement
|
||||
x_fclk_pending_reason = fields.Boolean(
|
||||
string='Pending Reason Required',
|
||||
default=False,
|
||||
help="If set, employee must explain a missed clock-out before clocking in again.",
|
||||
)
|
||||
|
||||
# Kiosk PIN
|
||||
x_fclk_kiosk_pin = fields.Char(
|
||||
string='Kiosk PIN',
|
||||
help="PIN code for kiosk clock-in/out identification.",
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
)
|
||||
|
||||
# On-time streak
|
||||
x_fclk_ontime_streak = fields.Integer(
|
||||
string='On-Time Streak',
|
||||
default=0,
|
||||
help="Consecutive workdays clocked in on time.",
|
||||
)
|
||||
|
||||
# Absence tracking (computed)
|
||||
x_fclk_absences_this_month = fields.Integer(
|
||||
string='Absences This Month',
|
||||
compute='_compute_absence_counts',
|
||||
)
|
||||
x_fclk_absences_this_year = fields.Integer(
|
||||
string='Absences This Year',
|
||||
compute='_compute_absence_counts',
|
||||
)
|
||||
|
||||
# Overtime tracking (computed)
|
||||
x_fclk_overtime_this_week = fields.Float(
|
||||
string='Overtime This Week (h)',
|
||||
compute='_compute_overtime',
|
||||
)
|
||||
x_fclk_overtime_this_month = fields.Float(
|
||||
string='Overtime This Month (h)',
|
||||
compute='_compute_overtime',
|
||||
)
|
||||
|
||||
# Activity log relation
|
||||
x_fclk_activity_log_ids = fields.One2many(
|
||||
'fusion.clock.activity.log',
|
||||
'employee_id',
|
||||
string='Activity Logs',
|
||||
)
|
||||
|
||||
# Leave request relation
|
||||
x_fclk_leave_request_ids = fields.One2many(
|
||||
'fusion.clock.leave.request',
|
||||
'employee_id',
|
||||
string='Leave Requests',
|
||||
)
|
||||
|
||||
# Correction request relation
|
||||
x_fclk_correction_ids = fields.One2many(
|
||||
'fusion.clock.correction',
|
||||
'employee_id',
|
||||
string='Correction Requests',
|
||||
)
|
||||
|
||||
# Reminder tracking
|
||||
x_fclk_last_reminder_date = fields.Date(
|
||||
string='Last Reminder Date',
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee."""
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: employee override > shift > global setting.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
||||
return self.x_fclk_shift_id.break_minutes
|
||||
return float(
|
||||
self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.default_break_minutes', '30'
|
||||
)
|
||||
)
|
||||
|
||||
def _get_fclk_scheduled_times(self, date):
|
||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||
Uses employee shift if assigned, otherwise global settings.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
out_hour = self.x_fclk_shift_id.end_time
|
||||
else:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
|
||||
in_h = int(in_hour)
|
||||
in_m = int((in_hour - in_h) * 60)
|
||||
out_h = int(out_hour)
|
||||
out_m = int((out_hour - out_h) * 60)
|
||||
|
||||
scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
|
||||
scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
|
||||
return scheduled_in, scheduled_out
|
||||
|
||||
def _get_fclk_scheduled_hours(self):
|
||||
"""Return the expected work hours for this employee's shift."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
return self.x_fclk_shift_id.scheduled_hours
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
||||
|
||||
def _compute_absence_counts(self):
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
|
||||
for emp in self:
|
||||
emp.x_fclk_absences_this_month = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
])
|
||||
emp.x_fclk_absences_this_year = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(year_start, datetime.min.time())),
|
||||
])
|
||||
|
||||
def _compute_overtime(self):
|
||||
Attendance = self.env['hr.attendance'].sudo()
|
||||
today = fields.Date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
for emp in self:
|
||||
week_atts = Attendance.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts)
|
||||
|
||||
month_atts = Attendance.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)
|
||||
|
||||
@@ -38,7 +38,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=5.0,
|
||||
default=4.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
|
||||
@@ -73,6 +73,116 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=5.0,
|
||||
help="Minutes of grace before a late/early penalty is recorded.",
|
||||
)
|
||||
fclk_penalty_deduction_minutes = fields.Float(
|
||||
string='Penalty Deduction (min)',
|
||||
config_parameter='fusion_clock.penalty_deduction_minutes',
|
||||
default=15.0,
|
||||
help="Minutes deducted from worked hours per penalty occurrence.",
|
||||
)
|
||||
|
||||
# -- Office User & Notifications --
|
||||
fclk_office_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Office User',
|
||||
help="User who receives activity notifications for attendance issues.",
|
||||
)
|
||||
fclk_very_late_threshold_minutes = fields.Float(
|
||||
string='Very Late Threshold (min)',
|
||||
config_parameter='fusion_clock.very_late_threshold_minutes',
|
||||
default=15.0,
|
||||
help="Minutes late before an activity is scheduled for the office user.",
|
||||
)
|
||||
fclk_max_monthly_absences = fields.Integer(
|
||||
string='Max Monthly Absences',
|
||||
config_parameter='fusion_clock.max_monthly_absences',
|
||||
default=3,
|
||||
help="Alert office user when an employee reaches this many absences in a month.",
|
||||
)
|
||||
fclk_enable_employee_notifications = fields.Boolean(
|
||||
string='Enable Employee Notifications',
|
||||
config_parameter='fusion_clock.enable_employee_notifications',
|
||||
default=True,
|
||||
help="Send clock-in/out reminders to employees.",
|
||||
)
|
||||
fclk_reminder_before_shift_minutes = fields.Float(
|
||||
string='Remind After Shift Start (min)',
|
||||
config_parameter='fusion_clock.reminder_before_shift_minutes',
|
||||
default=30.0,
|
||||
help="Send reminder if employee hasn't clocked in this many minutes after shift start.",
|
||||
)
|
||||
fclk_reminder_before_end_minutes = fields.Float(
|
||||
string='Remind Before Shift End (min)',
|
||||
config_parameter='fusion_clock.reminder_before_end_minutes',
|
||||
default=15.0,
|
||||
help="Send clock-out reminder this many minutes before shift end.",
|
||||
)
|
||||
fclk_send_weekly_summary = fields.Boolean(
|
||||
string='Send Weekly Summary',
|
||||
config_parameter='fusion_clock.send_weekly_summary',
|
||||
default=True,
|
||||
help="Send weekly attendance summary to each employee on Monday.",
|
||||
)
|
||||
|
||||
# -- Overtime --
|
||||
fclk_enable_overtime = fields.Boolean(
|
||||
string='Enable Overtime Tracking',
|
||||
config_parameter='fusion_clock.enable_overtime',
|
||||
default=True,
|
||||
)
|
||||
fclk_daily_overtime_threshold = fields.Float(
|
||||
string='Daily OT Threshold (hours)',
|
||||
config_parameter='fusion_clock.daily_overtime_threshold',
|
||||
default=8.0,
|
||||
help="Net hours beyond this threshold count as daily overtime.",
|
||||
)
|
||||
fclk_weekly_overtime_threshold = fields.Float(
|
||||
string='Weekly OT Threshold (hours)',
|
||||
config_parameter='fusion_clock.weekly_overtime_threshold',
|
||||
default=40.0,
|
||||
help="Net hours beyond this threshold count as weekly overtime.",
|
||||
)
|
||||
|
||||
# -- Location --
|
||||
fclk_enable_ip_fallback = fields.Boolean(
|
||||
string='Enable IP Fallback',
|
||||
config_parameter='fusion_clock.enable_ip_fallback',
|
||||
default=False,
|
||||
help="Allow IP-based location verification when GPS is unavailable.",
|
||||
)
|
||||
fclk_enable_photo_verification = fields.Boolean(
|
||||
string='Enable Photo Verification',
|
||||
config_parameter='fusion_clock.enable_photo_verification',
|
||||
default=False,
|
||||
help="Global toggle for selfie verification on clock-in (per-location control).",
|
||||
)
|
||||
|
||||
# -- Kiosk --
|
||||
fclk_enable_kiosk = fields.Boolean(
|
||||
string='Enable Kiosk Mode',
|
||||
config_parameter='fusion_clock.enable_kiosk',
|
||||
default=False,
|
||||
)
|
||||
fclk_kiosk_pin_required = fields.Boolean(
|
||||
string='Require PIN for Kiosk',
|
||||
config_parameter='fusion_clock.kiosk_pin_required',
|
||||
default=True,
|
||||
help="Require employees to enter a PIN when using kiosk mode.",
|
||||
)
|
||||
|
||||
# -- Corrections --
|
||||
fclk_enable_correction_requests = fields.Boolean(
|
||||
string='Enable Correction Requests',
|
||||
config_parameter='fusion_clock.enable_correction_requests',
|
||||
default=True,
|
||||
help="Allow employees to request timesheet corrections from the portal.",
|
||||
)
|
||||
|
||||
# -- CSV Export --
|
||||
fclk_csv_column_mapping = fields.Char(
|
||||
string='CSV Column Mapping',
|
||||
config_parameter='fusion_clock.csv_column_mapping',
|
||||
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
|
||||
)
|
||||
|
||||
# -- Pay Period --
|
||||
fclk_pay_period_type = fields.Selection(
|
||||
@@ -89,7 +199,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_pay_period_start = fields.Char(
|
||||
string='Pay Period Anchor Date',
|
||||
config_parameter='fusion_clock.pay_period_start',
|
||||
help="Start date for pay period calculations (YYYY-MM-DD format, anchor for weekly/biweekly).",
|
||||
help="Start date for pay period calculations (YYYY-MM-DD format).",
|
||||
)
|
||||
|
||||
# -- Reports --
|
||||
@@ -122,3 +232,20 @@ class ResConfigSettings(models.TransientModel):
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if self.fclk_office_user_id:
|
||||
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
|
||||
else:
|
||||
ICP.set_param('fusion_clock.office_user_id', '0')
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super().get_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if office_user_id:
|
||||
res['fclk_office_user_id'] = office_user_id
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user