Files
Odoo-Modules/fusion_clock/models/hr_attendance.py
gsinghpal e8e554de95 changes
2026-02-23 00:32:20 -05:00

153 lines
5.6 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import datetime, timedelta
from odoo import models, fields, api
from odoo.tools import float_round
_logger = logging.getLogger(__name__)
class HrAttendance(models.Model):
_inherit = 'hr.attendance'
x_fclk_location_id = fields.Many2one(
'fusion.clock.location',
string='Clock Location',
help="The geofenced location where employee clocked in.",
)
x_fclk_clock_source = fields.Selection(
[
('portal', 'Portal'),
('systray', 'Systray'),
('kiosk', 'Kiosk'),
('manual', 'Manual'),
('auto', 'Auto Clock-Out'),
],
string='Clock Source',
help="How this attendance was recorded.",
)
x_fclk_in_distance = fields.Float(
string='Check-In Distance (m)',
digits=(10, 2),
help="Distance from location center at clock-in, in meters.",
)
x_fclk_out_distance = fields.Float(
string='Check-Out Distance (m)',
digits=(10, 2),
help="Distance from location center at clock-out, in meters.",
)
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
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,
help="Worked hours minus break deduction.",
)
x_fclk_penalty_ids = fields.One2many(
'fusion.clock.penalty',
'attendance_id',
string='Penalties',
)
x_fclk_auto_clocked_out = fields.Boolean(
string='Auto Clocked Out',
default=False,
help="Set to true if this attendance was automatically closed.",
)
x_fclk_grace_used = fields.Boolean(
string='Grace Period Used',
default=False,
help="Whether the grace period was consumed before auto clock-out.",
)
@api.depends('worked_hours', 'x_fclk_break_minutes')
def _compute_net_hours(self):
for att in self:
break_hours = (att.x_fclk_break_minutes or 0.0) / 60.0
raw = att.worked_hours or 0.0
att.x_fclk_net_hours = max(raw - break_hours, 0.0)
@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.
"""
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'))
now = fields.Datetime.now()
# Find all open attendances (no check_out)
open_attendances = self.sudo().search([
('check_out', '=', False),
])
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)
# Also check max shift safety net
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({
'check_out': clock_out_time,
'x_fclk_auto_clocked_out': True,
'x_fclk_grace_used': True,
'x_fclk_clock_source': 'auto',
})
# Apply break deduction
employee = att.employee_id
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.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',
)
_logger.info(
"Fusion Clock: Auto clocked out %s (attendance %s)",
employee.name, att.id,
)
except Exception as e:
_logger.error(
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
att.id, str(e),
)