Initial commit
This commit is contained in:
330
fusion_clock/models/hr_attendance.py
Normal file
330
fusion_clock/models/hr_attendance.py
Normal file
@@ -0,0 +1,330 @@
|
||||
# -*- 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-In Location',
|
||||
help="The geofenced location where employee clocked in.",
|
||||
)
|
||||
x_fclk_out_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Clock-Out Location',
|
||||
help="The geofenced location where employee clocked out.",
|
||||
)
|
||||
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',
|
||||
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.",
|
||||
)
|
||||
|
||||
# -- Pay period grouping fields --
|
||||
x_fclk_pay_period = fields.Char(
|
||||
string='Pay Period',
|
||||
compute='_compute_pay_period',
|
||||
store=True,
|
||||
help="Human-readable pay period label for grouping.",
|
||||
)
|
||||
x_fclk_pay_period_start = fields.Date(
|
||||
string='Period Start',
|
||||
compute='_compute_pay_period',
|
||||
store=True,
|
||||
help="Pay period start date, used for chronological ordering.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD overrides: auto-apply break on any attendance with check_out
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
records._auto_apply_break()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'check_out' in vals or 'worked_hours' in vals:
|
||||
self._auto_apply_break()
|
||||
return res
|
||||
|
||||
def _auto_apply_break(self):
|
||||
"""Apply break deduction to completed attendances that don't have it.
|
||||
|
||||
Only applies when:
|
||||
- auto_deduct_break setting is enabled
|
||||
- check_out is set (completed shift)
|
||||
- worked_hours >= break threshold
|
||||
- break_minutes is still 0 (not manually set or already applied)
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
|
||||
for att in self:
|
||||
if not att.check_out:
|
||||
continue
|
||||
if (att.x_fclk_break_minutes or 0) > 0:
|
||||
continue
|
||||
if (att.worked_hours or 0) < threshold:
|
||||
continue
|
||||
|
||||
emp = att.employee_id
|
||||
if emp:
|
||||
break_min = emp._get_fclk_break_minutes()
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
@api.model
|
||||
def action_backfill_breaks(self):
|
||||
"""Apply break deduction to all historical records that are missing it."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
|
||||
records = self.sudo().search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_break_minutes', '=', 0),
|
||||
])
|
||||
|
||||
count = 0
|
||||
for att in records:
|
||||
if (att.worked_hours or 0) < threshold:
|
||||
continue
|
||||
emp = att.employee_id
|
||||
if emp:
|
||||
break_min = emp._get_fclk_break_minutes()
|
||||
att.write({'x_fclk_break_minutes': break_min})
|
||||
count += 1
|
||||
|
||||
_logger.info("Fusion Clock: Backfilled break on %d attendance records.", count)
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@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.depends('check_in')
|
||||
def _compute_pay_period(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
anchor = None
|
||||
else:
|
||||
anchor = None
|
||||
|
||||
for att in self:
|
||||
if not att.check_in:
|
||||
att.x_fclk_pay_period = False
|
||||
att.x_fclk_pay_period_start = False
|
||||
continue
|
||||
|
||||
ref_date = att.check_in.date()
|
||||
period_start, period_end = self._calc_period(
|
||||
schedule_type, anchor, ref_date,
|
||||
)
|
||||
att.x_fclk_pay_period_start = period_start
|
||||
att.x_fclk_pay_period = (
|
||||
f"{period_start.strftime('%b %d')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calc_period(schedule_type, anchor, ref_date):
|
||||
"""Calculate pay period start/end for a given date."""
|
||||
if not anchor:
|
||||
anchor = ref_date.replace(day=1)
|
||||
|
||||
if schedule_type == 'weekly':
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 7
|
||||
period_start = anchor + timedelta(days=period_num * 7)
|
||||
period_end = period_start + timedelta(days=6)
|
||||
|
||||
elif schedule_type == 'biweekly':
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if ref_date.day <= 15:
|
||||
period_start = ref_date.replace(day=1)
|
||||
period_end = ref_date.replace(day=15)
|
||||
else:
|
||||
period_start = ref_date.replace(day=16)
|
||||
next_month = ref_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
elif schedule_type == 'monthly':
|
||||
period_start = ref_date.replace(day=1)
|
||||
next_month = ref_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
else:
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
return period_start, period_end
|
||||
|
||||
@api.model
|
||||
def _read_group(self, domain, groupby=(), aggregates=(), having=(),
|
||||
offset=0, limit=None, order=None):
|
||||
"""Sort pay period groups chronologically (newest first) using the
|
||||
stored Date field instead of alphabetical Char order."""
|
||||
if 'x_fclk_pay_period' in groupby:
|
||||
order = 'x_fclk_pay_period_start:max desc'
|
||||
return super()._read_group(
|
||||
domain, groupby, aggregates, having, offset, limit, order,
|
||||
)
|
||||
|
||||
def action_recompute_pay_periods(self):
|
||||
"""Recompute pay period for all attendance records. Called from settings."""
|
||||
all_atts = self.sudo().search([])
|
||||
all_atts._compute_pay_period()
|
||||
return True
|
||||
|
||||
@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),
|
||||
)
|
||||
Reference in New Issue
Block a user