This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -4,9 +4,12 @@
import base64
import logging
import pytz
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from .hr_attendance import _fclk_email_wrap, _fclk_utc_to_local_str
from .tz_utils import get_local_today, get_local_day_boundaries, _resolve_tz
_logger = logging.getLogger(__name__)
@@ -67,6 +70,7 @@ class FusionClockReport(models.Model):
total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True)
total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True)
days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True)
leave_days = fields.Integer(string='Leave Days', compute='_compute_totals', store=True)
attendance_ids = fields.Many2many(
'hr.attendance',
'fusion_clock_report_attendance_rel',
@@ -74,6 +78,13 @@ class FusionClockReport(models.Model):
'attendance_id',
string='Attendance Records',
)
leave_request_ids = fields.Many2many(
'fusion.clock.leave.request',
'fusion_clock_report_leave_rel',
'report_id',
'leave_request_id',
string='Leave Requests',
)
# PDF
report_pdf = fields.Binary(string='Report PDF', attachment=True)
@@ -93,7 +104,8 @@ class FusionClockReport(models.Model):
rec.is_batch = not bool(rec.employee_id)
@api.depends('attendance_ids', 'attendance_ids.worked_hours',
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes')
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes',
'leave_request_ids')
def _compute_totals(self):
for rec in self:
atts = rec.attendance_ids
@@ -105,12 +117,14 @@ class FusionClockReport(models.Model):
('date', '>=', rec.date_start),
('date', '<=', rec.date_end),
]) if rec.employee_id else 0
# Count unique dates
tz = _resolve_tz(rec.env, rec.employee_id)
dates = set()
for a in atts:
if a.check_in:
dates.add(a.check_in.date())
local_dt = pytz.UTC.localize(a.check_in).astimezone(tz)
dates.add(local_dt.date())
rec.days_worked = len(dates)
rec.leave_days = len(rec.leave_request_ids)
def action_generate_report(self):
"""Generate the PDF report for this record."""
@@ -119,6 +133,11 @@ class FusionClockReport(models.Model):
self._generate_pdf()
self.state = 'generated'
def action_reset_draft(self):
"""Reset the report back to draft so the user can make changes."""
for rec in self:
rec.state = 'draft'
def action_send_report(self):
"""Send the report via email."""
self.ensure_one()
@@ -128,11 +147,14 @@ class FusionClockReport(models.Model):
self.state = 'sent'
def _collect_attendance_records(self):
"""Link attendance records for the period and employee."""
"""Link attendance and leave records for the period and employee."""
self.ensure_one()
employee = self.employee_id or None
start_utc, _ = get_local_day_boundaries(self.env, self.date_start, employee)
_, end_utc = get_local_day_boundaries(self.env, self.date_end, employee)
domain = [
('check_in', '>=', fields.Datetime.to_datetime(self.date_start)),
('check_in', '<', fields.Datetime.to_datetime(self.date_end + timedelta(days=1))),
('check_in', '>=', start_utc),
('check_in', '<', end_utc),
('check_out', '!=', False),
]
if self.employee_id:
@@ -143,6 +165,18 @@ class FusionClockReport(models.Model):
attendances = self.env['hr.attendance'].search(domain)
self.attendance_ids = [(6, 0, attendances.ids)]
leave_domain = [
('leave_date', '>=', self.date_start),
('leave_date', '<=', self.date_end),
]
if self.employee_id:
leave_domain.append(('employee_id', '=', self.employee_id.id))
else:
leave_domain.append(('company_id', '=', self.company_id.id))
leaves = self.env['fusion.clock.leave.request'].search(leave_domain)
self.leave_request_ids = [(6, 0, leaves.ids)]
def _generate_pdf(self):
"""Render the QWeb report to PDF and store it."""
self.ensure_one()
@@ -172,52 +206,63 @@ class FusionClockReport(models.Model):
def _send_report_email(self):
"""Send the report with the PDF attached."""
self.ensure_one()
company_email = self.company_id.email or ''
company = self.company_id or self.env.company
company_email = company.email or ''
company_name = company.name or ''
ds_fmt = self.date_start.strftime('%b %d, %Y') if self.date_start else ''
de_fmt = self.date_end.strftime('%b %d, %Y') if self.date_end else ''
if self.employee_id:
email_to = self.employee_id.work_email or ''
subject = f"Your Attendance Report - {self.date_start} to {self.date_end}"
body = (
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">'
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">'
'<tr><td style="padding:24px 32px;background:#1a1d23;">'
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>'
'<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p>'
'</td></tr><tr><td style="padding:24px 32px;">'
f'<p>Hello <strong>{self.employee_id.name}</strong>,</p>'
f'<p>Your attendance report for <strong>{self.date_start}</strong> to '
f'<strong>{self.date_end}</strong> is ready.</p>'
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">'
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Days Worked</td>'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.days_worked}</td></tr>'
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Hours</td>'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_hours:.1f}h</td></tr>'
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Net Hours</td>'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.net_hours:.1f}h</td></tr>'
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Breaks</td>'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_breaks:.0f} min</td></tr>'
'</table>'
'<p>The full PDF report is attached.</p>'
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
'</td></tr></table></div>'
subject = f"Your Attendance Report - {ds_fmt} to {de_fmt}"
body = _fclk_email_wrap(
company_name=company_name,
title='Attendance Report',
summary=(
f'Hello <strong>{self.employee_id.name}</strong>, your attendance '
f'report for <strong>{ds_fmt}</strong> to '
f'<strong>{de_fmt}</strong> is ready.'
),
sections=[('Report Summary', [
('Pay Period', f'{ds_fmt} &mdash; {de_fmt}'),
('Days Worked', str(self.days_worked)),
('Leave Days', str(self.leave_days)),
('Total Hours', f'{self.total_hours:.1f}h'),
('Net Hours', f'{self.net_hours:.1f}h'),
('Total Breaks', f'{self.total_breaks:.0f} min'),
('Penalties', str(self.total_penalties)),
])],
note='The full PDF report is attached. You can also download '
'it from <a href="/my/clock" style="color:#10B981;">'
'your portal</a> at any time.',
attachments_note='Attendance Report (PDF)',
)
else:
ICP = self.env['ir.config_parameter'].sudo()
email_to = ICP.get_param('fusion_clock.report_recipient_emails', '')
subject = f"Employee Attendance Batch Report - {self.date_start} to {self.date_end}"
body = (
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">'
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">'
'<tr><td style="padding:24px 32px;background:#1a1d23;">'
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>'
'<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p>'
'</td></tr><tr><td style="padding:24px 32px;">'
f'<p>The attendance batch report for <strong>{self.date_start}</strong> to '
f'<strong>{self.date_end}</strong> is attached.</p>'
'<p>This report includes all employees\' attendance summaries with daily breakdowns, '
'total hours, and penalty information.</p>'
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
'</td></tr></table></div>'
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
if user_ids_str:
try:
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
users = self.env['res.users'].sudo().browse(user_ids).filtered('email')
user_emails = ','.join(u.email for u in users if u.email)
if email_to and user_emails:
email_to = f"{email_to},{user_emails}"
elif user_emails:
email_to = user_emails
except (ValueError, TypeError):
pass
subject = f"Employee Attendance Batch Report - {ds_fmt} to {de_fmt}"
body = _fclk_email_wrap(
company_name=company_name,
title='Batch Attendance Report',
summary=(
f'The attendance batch report for <strong>{ds_fmt}</strong> to '
f'<strong>{de_fmt}</strong> is attached.'
),
note='This report includes all employees\' attendance summaries '
'with daily breakdowns, total hours, and penalty information.',
attachments_note='Batch Attendance Report (PDF)',
)
if not email_to:
@@ -283,8 +328,8 @@ class FusionClockReport(models.Model):
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 ''
in_str = _fclk_utc_to_local_str(att.check_in, att.employee_id, '%H:%M') if att.check_in else ''
out_str = _fclk_utc_to_local_str(att.check_out, att.employee_id, '%H:%M') if att.check_out else ''
penalties = self.env['fusion.clock.penalty'].search_count([
('attendance_id', '=', att.id),
])
@@ -301,6 +346,20 @@ class FusionClockReport(models.Model):
att.x_fclk_location_id.name or '',
])
for lv in self.leave_request_ids.sorted(key=lambda l: l.leave_date):
writer.writerow([
lv.employee_id.name or '',
str(lv.leave_date),
'LEAVE',
'LEAVE',
0,
0,
0,
0,
0,
lv.reason or '',
])
csv_data = output.getvalue().encode('utf-8')
output.close()
@@ -333,11 +392,10 @@ class FusionClockReport(models.Model):
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
today = fields.Date.today()
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, today)
# Only generate if yesterday was the end of a period
if period_end != today - timedelta(days=1):
today = get_local_today(self.env)
yesterday = today - timedelta(days=1)
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, yesterday)
if period_end != yesterday:
return
_logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end)
@@ -443,3 +501,86 @@ class FusionClockReport(models.Model):
period_end = period_start + timedelta(days=13)
return period_start, period_end
@api.model
def action_generate_historical_reports(self):
"""Generate reports for all past pay periods from historical attendance data.
Iterates per-employee so each starts from their own earliest
attendance record rather than the global earliest. Includes ALL
employees who have completed attendance records, not only those
with ``x_fclk_enable_clock``.
"""
ICP = self.env['ir.config_parameter'].sudo()
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
today = get_local_today(self.env)
created_count = 0
emp_groups = self.env['hr.attendance'].sudo().read_group(
[('check_out', '!=', False)],
['employee_id'],
['employee_id'],
)
for emp_data in emp_groups:
emp_id = emp_data['employee_id'][0]
employee = self.env['hr.employee'].sudo().browse(emp_id)
if not employee.exists():
continue
earliest = self.env['hr.attendance'].sudo().search(
[('employee_id', '=', emp_id), ('check_out', '!=', False)],
order='check_in asc',
limit=1,
)
if not earliest:
continue
current = earliest.check_in.date()
while current <= today:
period_start, period_end = self._calculate_current_period(
schedule_type, period_start_str, current,
)
if period_end > today:
break
existing = self.sudo().search([
('employee_id', '=', emp_id),
('date_start', '=', period_start),
('date_end', '=', period_end),
], limit=1)
if not existing:
att_count = self.env['hr.attendance'].sudo().search_count([
('employee_id', '=', emp_id),
('check_in', '>=', fields.Datetime.to_datetime(period_start)),
('check_in', '<', fields.Datetime.to_datetime(
period_end + timedelta(days=1),
)),
('check_out', '!=', False),
])
if att_count > 0:
report = self.sudo().create({
'date_start': period_start,
'date_end': period_end,
'schedule_type': schedule_type,
'employee_id': emp_id,
'company_id': employee.company_id.id,
})
report._collect_attendance_records()
created_count += 1
current = period_end + timedelta(days=1)
_logger.info("Fusion Clock: Generated %d historical reports", created_count)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Historical Reports',
'message': f'{created_count} reports generated from historical data.',
'type': 'success',
'sticky': False,
},
}

View File

@@ -3,13 +3,118 @@
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import pytz
from datetime import datetime, timedelta
from odoo import models, fields, api
from odoo.tools import float_round
from .tz_utils import get_local_today, get_local_day_boundaries
_logger = logging.getLogger(__name__)
def _fclk_utc_to_local_str(dt, employee, fmt='%I:%M %p'):
"""Convert a naive UTC datetime to a formatted string in the employee's timezone."""
import pytz
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
utc_dt = pytz.UTC.localize(dt)
local_dt = utc_dt.astimezone(pytz.timezone(tz_name))
return local_dt.strftime(fmt)
_FCLK_ACCENT = '#10B981'
_FCLK_FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif")
def _fclk_email_section(heading, rows):
"""Build a details table matching the Fusion email design system."""
if not rows:
return ''
html = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
f'<td style="padding:10px 14px;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _fclk_email_wrap(
company_name,
title,
summary,
sections=None,
note=None,
attachments_note=None,
extra_html='',
):
"""Build a complete Fusion Clock email matching the Fusion design system.
No user signatures are appended.
"""
parts = [
f'<div style="font-family:{_FCLK_FONT};max-width:600px;margin:0 auto;">',
f'<div style="height:4px;background-color:{_FCLK_ACCENT};"></div>',
'<div style="padding:32px 28px;">',
f'<p style="color:{_FCLK_ACCENT};font-size:13px;font-weight:600;'
f'letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">'
f'{company_name}</p>',
f'<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;'
f'line-height:1.3;">{title}</h2>',
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>',
]
if sections:
for heading, rows in sections:
parts.append(_fclk_email_section(heading, rows))
if note:
parts.append(
f'<div style="border-left:3px solid {_FCLK_ACCENT};padding:12px 16px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;">{note}</p></div>'
)
if extra_html:
parts.append(extra_html)
if attachments_note:
parts.append(
'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);'
'border-radius:6px;margin:0 0 24px 0;">'
'<p style="margin:0;font-size:13px;opacity:0.65;">'
f'<strong style="opacity:1;">Attached:</strong> {attachments_note}</p></div>'
)
parts.append('</div>')
parts.append(
'<div style="padding:16px 28px;text-align:center;">'
'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
f'{company_name}<br/>'
'This is an automated notification from Fusion Clock.</p></div>'
)
parts.append('</div>')
return ''.join(parts)
class HrAttendance(models.Model):
_inherit = 'hr.attendance'
@@ -146,7 +251,9 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date())
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift)
@@ -169,7 +276,7 @@ class HrAttendance(models.Model):
att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post(
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} "
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
message_type='comment',
subtype_xmlid='mail.mt_note',
@@ -179,7 +286,7 @@ class HrAttendance(models.Model):
ActivityLog.create({
'employee_id': employee.id,
'log_type': 'auto_clock_out',
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. "
'description': f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%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,
@@ -193,7 +300,7 @@ class HrAttendance(models.Model):
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"{employee.name} was auto-clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. "
f"Please review and correct if needed.",
'hr.attendance',
att.id,
@@ -216,21 +323,6 @@ class HrAttendance(models.Model):
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),
])
@@ -239,16 +331,29 @@ class HrAttendance(models.Model):
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
for emp in employees:
# Check for attendance yesterday
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
if yesterday.weekday() >= 5:
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', day_end),
('date_to', '>=', day_start),
])
if holidays:
continue
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())),
('check_in', '>=', day_start),
('check_in', '<', day_end),
])
if att_count > 0:
continue
# Check for approved leave
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
@@ -256,23 +361,22 @@ class HrAttendance(models.Model):
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)),
'log_date': day_start,
'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)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
('log_date', '>=', month_boundary_start),
])
if absence_count >= max_absences:
@@ -298,17 +402,17 @@ class HrAttendance(models.Model):
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:
today = get_local_today(self.env, emp)
if today.weekday() >= 5:
continue
if emp.x_fclk_last_reminder_date == today:
continue
@@ -318,16 +422,17 @@ class HrAttendance(models.Model):
# Missed clock-in reminder
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
if not is_checked_in and now > reminder_deadline:
today_start, _ = get_local_day_boundaries(self.env, today, emp)
has_attendance = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(today, datetime.min.time())),
('check_in', '>=', today_start),
])
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')}.",
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
@@ -337,7 +442,7 @@ class HrAttendance(models.Model):
self._fclk_send_employee_reminder(
emp,
"Clock-Out Reminder",
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. "
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
f"Don't forget to clock out.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
@@ -349,27 +454,34 @@ class HrAttendance(models.Model):
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),
])
company_email = self.env.company.email or ''
company = self.env.company
company_email = company.email or ''
company_name = company.name or ''
for emp in employees:
if not emp.work_email:
continue
today = get_local_today(self.env, emp)
if today.weekday() != 0:
continue
week_start = today - timedelta(days=7)
week_end = today - timedelta(days=1)
ws_fmt = week_start.strftime('%b %d, %Y')
we_fmt = week_end.strftime('%b %d, %Y')
ws_start, _ = get_local_day_boundaries(self.env, week_start, emp)
_, we_end = get_local_day_boundaries(self.env, week_end, emp)
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_in', '>=', ws_start),
('check_in', '<', we_end),
('check_out', '!=', False),
])
@@ -385,47 +497,36 @@ class HrAttendance(models.Model):
absence_count = 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())),
('log_date', '>=', ws_start),
('log_date', '<', we_end),
])
streak = emp.x_fclk_ontime_streak or 0
emp_company = emp.company_id or company
def _row(label, value, bg=False):
bg_style = 'background:#f8f9fa;' if bg else ''
return (
f'<tr style="{bg_style}">'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">{label}</td>'
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{value}</td>'
f'</tr>'
)
body = (
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">'
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">'
'<tr><td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">'
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>'
'<p style="color:#9ca3af;margin:4px 0 0;">Weekly Summary</p>'
'</td></tr>'
'<tr><td style="padding:24px 32px;">'
f'<p>Hello <strong>{emp.name}</strong>,</p>'
f'<p>Here is your attendance summary for <strong>{week_start}</strong> to <strong>{week_end}</strong>:</p>'
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">'
+ _row('Total Hours', f'{total_net}h', True)
+ _row('Overtime', f'{total_ot}h', False)
+ _row('Penalties', str(penalty_count), True)
+ _row('Absences', str(absence_count), False)
+ _row('On-Time Streak', f'{streak} days', True)
+ '</table>'
'<p>Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> to view details.</p>'
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
'</td></tr></table></div>'
body = _fclk_email_wrap(
company_name=emp_company.name or company_name,
title='Weekly Summary',
summary=(
f'Hello <strong>{emp.name}</strong>, here is your attendance '
f'summary for <strong>{ws_fmt}</strong> to <strong>{we_fmt}</strong>.'
),
sections=[('Summary', [
('Total Hours', f'{total_net}h'),
('Overtime', f'{total_ot}h'),
('Penalties', str(penalty_count)),
('Absences', str(absence_count)),
('On-Time Streak', f'{streak} days'),
])],
note='Log in to <a href="/my/clock" style="color:#10B981;">'
'your portal</a> to view full details.',
)
try:
from_email = emp_company.email or company_email
mail = self.env['mail.mail'].sudo().create({
'subject': f'Your Weekly Attendance Summary ({week_start} - {week_end})',
'email_from': company_email,
'subject': f'Your Weekly Attendance Summary ({ws_fmt} - {we_fmt})',
'email_from': from_email,
'email_to': emp.work_email,
'body_html': body,
'auto_delete': True,
@@ -450,7 +551,7 @@ class HrAttendance(models.Model):
'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(),
'date_deadline': get_local_today(self.env),
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create office activity: %s", e)
@@ -469,9 +570,20 @@ class HrAttendance(models.Model):
pass
try:
if employee.work_email:
company = employee.company_id or self.env.company
company_email = company.email or ''
company_name = company.name or ''
html_body = _fclk_email_wrap(
company_name=company_name,
title=subject,
summary=body,
note='Log in to <a href="/my/clock" style="color:#10B981;">'
'your portal</a> to view your attendance details.',
)
mail_values = {
'subject': f"Fusion Clock: {subject}",
'body_html': f"<p>{body}</p>",
'email_from': company_email,
'body_html': html_body,
'email_to': employee.work_email,
'auto_delete': True,
}

View File

@@ -4,6 +4,7 @@
from datetime import datetime, timedelta
from odoo import models, fields, api
from .tz_utils import get_local_today, get_local_day_boundaries
class HrEmployee(models.Model):
@@ -117,8 +118,14 @@ class HrEmployee(models.Model):
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.
The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
"""
import pytz
self.ensure_one()
if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time
@@ -133,8 +140,24 @@ class HrEmployee(models.Model):
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))
tz_name = (
self.resource_id.tz
or (self.user_id.partner_id.tz if self.user_id else False)
or self.company_id.partner_id.tz
or 'UTC'
)
local_tz = pytz.timezone(tz_name)
utc = pytz.UTC
local_in = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
)
local_out = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
)
scheduled_in = local_in.astimezone(utc).replace(tzinfo=None)
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self):
@@ -150,39 +173,44 @@ class HrEmployee(models.Model):
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:
today = get_local_today(self.env, emp)
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
year_start_utc, _ = get_local_day_boundaries(self.env, year_start, emp)
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())),
('log_date', '>=', month_start_utc),
])
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())),
('log_date', '>=', year_start_utc),
])
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:
today = get_local_today(self.env, emp)
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
week_start_utc, _ = get_local_day_boundaries(self.env, week_start, emp)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
week_atts = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
('check_in', '>=', week_start_utc),
('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_in', '>=', month_start_utc),
('check_out', '!=', False),
])
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)

View File

@@ -8,7 +8,7 @@ from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# -- Work Schedule --
# ── Work Schedule ──────────────────────────────────────────────────
fclk_default_clock_in_time = fields.Float(
string='Default Clock-In Time',
config_parameter='fusion_clock.default_clock_in_time',
@@ -21,20 +21,18 @@ class ResConfigSettings(models.TransientModel):
default=17.0,
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
)
# -- Break --
fclk_default_break_minutes = fields.Float(
string='Default Break Duration (min)',
config_parameter='fusion_clock.default_break_minutes',
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_auto_deduct_break = fields.Boolean(
string='Auto-Deduct Break',
config_parameter='fusion_clock.auto_deduct_break',
default=True,
help="Automatically deduct break from worked hours on clock-out.",
)
fclk_default_break_minutes = fields.Float(
string='Default Break Duration (min)',
config_parameter='fusion_clock.default_break_minutes',
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
@@ -42,30 +40,30 @@ class ResConfigSettings(models.TransientModel):
help="Only deduct break if shift is longer than this many hours.",
)
# -- Grace Period & Auto Clock-Out --
# ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out',
config_parameter='fusion_clock.enable_auto_clockout',
default=True,
help="Automatically clock out employees who forget. Triggers after shift end time plus grace period, or after max shift hours.",
)
fclk_grace_period_minutes = fields.Float(
string='Grace Period (min)',
config_parameter='fusion_clock.grace_period_minutes',
default=15.0,
help="Minutes allowed after scheduled end before auto clock-out.",
)
fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out',
config_parameter='fusion_clock.enable_auto_clockout',
default=True,
)
fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)',
config_parameter='fusion_clock.max_shift_hours',
default=12.0,
help="Maximum shift length before auto clock-out (safety net).",
)
# -- Penalties --
fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking',
config_parameter='fusion_clock.enable_penalties',
default=True,
help="Deduct minutes from worked hours when employees clock in late or clock out early.",
)
fclk_penalty_grace_minutes = fields.Float(
string='Penalty Grace (min)',
@@ -79,8 +77,26 @@ class ResConfigSettings(models.TransientModel):
default=15.0,
help="Minutes deducted from worked hours per penalty occurrence.",
)
fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking',
config_parameter='fusion_clock.enable_overtime',
default=True,
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.",
)
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.",
)
# -- Office User & Notifications --
# ── Notifications ──────────────────────────────────────────────────
fclk_office_user_id = fields.Many2one(
'res.users',
string='Office User',
@@ -123,26 +139,7 @@ class ResConfigSettings(models.TransientModel):
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 --
# ── Location & Verification ────────────────────────────────────────
fclk_enable_ip_fallback = fields.Boolean(
string='Enable IP Fallback',
config_parameter='fusion_clock.enable_ip_fallback',
@@ -155,12 +152,17 @@ class ResConfigSettings(models.TransientModel):
default=False,
help="Global toggle for selfie verification on clock-in (per-location control).",
)
fclk_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_clock.google_maps_api_key',
)
# -- Kiosk --
# ── Kiosk & Portal ─────────────────────────────────────────────────
fclk_enable_kiosk = fields.Boolean(
string='Enable Kiosk Mode',
config_parameter='fusion_clock.enable_kiosk',
default=False,
help="Allow employees to clock in/out from a shared device using their PIN code.",
)
fclk_kiosk_pin_required = fields.Boolean(
string='Require PIN for Kiosk',
@@ -168,23 +170,20 @@ class ResConfigSettings(models.TransientModel):
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.",
fclk_enable_sounds = fields.Boolean(
string='Enable Clock Sounds',
config_parameter='fusion_clock.enable_sounds',
default=True,
help="Play audio confirmation sounds when employees clock in or out.",
)
# -- Pay Period --
# ── Pay Period & Reports ───────────────────────────────────────────
fclk_pay_period_type = fields.Selection(
[
('weekly', 'Weekly'),
@@ -195,42 +194,42 @@ class ResConfigSettings(models.TransientModel):
string='Pay Period',
config_parameter='fusion_clock.pay_period_type',
default='biweekly',
help="How often attendance reports are generated.",
)
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).",
)
# -- Reports --
fclk_auto_generate_reports = fields.Boolean(
string='Auto-Generate Reports',
config_parameter='fusion_clock.auto_generate_reports',
default=True,
help="Automatically create attendance reports at the end of each pay period.",
)
fclk_send_employee_reports = fields.Boolean(
string='Send Employee Copies',
config_parameter='fusion_clock.send_employee_reports',
default=True,
help="Send each employee a copy of their individual attendance report.",
)
fclk_report_recipient_user_ids = fields.Many2many(
'res.users',
'fclk_report_recipient_user_rel',
'config_id',
'user_id',
string='Internal Report Recipients',
help="Internal users who will receive batch reports.",
)
fclk_report_recipient_emails = fields.Char(
string='Report Recipient Emails',
config_parameter='fusion_clock.report_recipient_emails',
help="Comma-separated email addresses for batch report delivery.",
)
fclk_send_employee_reports = fields.Boolean(
string='Send Employee Copies',
config_parameter='fusion_clock.send_employee_reports',
default=True,
help="Send individual report copies to each employee's work email.",
)
# -- Google Maps --
fclk_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_clock.google_maps_api_key',
)
# -- Sounds --
fclk_enable_sounds = fields.Boolean(
string='Enable Clock Sounds',
config_parameter='fusion_clock.enable_sounds',
default=True,
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.",
)
def set_values(self):
@@ -240,6 +239,11 @@ class ResConfigSettings(models.TransientModel):
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')
if self.fclk_report_recipient_user_ids:
ICP.set_param('fusion_clock.report_recipient_user_ids',
','.join(str(uid) for uid in self.fclk_report_recipient_user_ids.ids))
else:
ICP.set_param('fusion_clock.report_recipient_user_ids', '')
@api.model
def get_values(self):
@@ -248,4 +252,11 @@ class ResConfigSettings(models.TransientModel):
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
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
if user_ids_str:
try:
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
res['fclk_report_recipient_user_ids'] = [(6, 0, user_ids)]
except (ValueError, TypeError):
pass
return res

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Timezone helpers for Fusion Clock.
All Odoo datetimes are stored in UTC. These helpers derive "today",
date boundaries, and display strings in the **user's local timezone**
so that queries, penalties, and UI all reflect the real calendar day.
Timezone resolution order:
1. Explicit employee.tz (if an employee record is available)
2. env.user.tz (logged-in portal / backend user)
3. env.company.tz (company-level default)
4. 'UTC' (last resort — should rarely happen)
"""
import pytz
from datetime import datetime, timedelta
def _resolve_tz(env, employee=None):
"""Return a pytz timezone from the best available source."""
tz_name = (
(employee.tz if employee else None)
or env.user.tz
or env.company.tz
or 'UTC'
)
try:
return pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
return pytz.UTC
def get_local_now(env, employee=None):
"""Return the current datetime in the resolved local timezone (aware)."""
tz = _resolve_tz(env, employee)
return datetime.now(pytz.UTC).astimezone(tz)
def get_local_today(env, employee=None):
"""Return today's date in the resolved local timezone."""
return get_local_now(env, employee).date()
def get_local_day_boundaries(env, date_val, employee=None):
"""
Return (start_utc, end_utc) as **naive** UTC datetimes representing
midnight-to-midnight of *date_val* in the local timezone.
Suitable for Odoo domain filters like:
('check_in', '>=', start_utc),
('check_in', '<', end_utc),
"""
tz = _resolve_tz(env, employee)
local_start = tz.localize(datetime.combine(date_val, datetime.min.time()))
local_end = tz.localize(datetime.combine(date_val + timedelta(days=1), datetime.min.time()))
return (
local_start.astimezone(pytz.UTC).replace(tzinfo=None),
local_end.astimezone(pytz.UTC).replace(tzinfo=None),
)
def utc_to_local_str(dt_utc, env, employee=None, fmt='%I:%M %p'):
"""
Convert a naive-UTC datetime to a formatted string in local timezone.
Returns '' if dt_utc is falsy.
"""
if not dt_utc:
return ''
tz = _resolve_tz(env, employee)
aware = pytz.UTC.localize(dt_utc)
return aware.astimezone(tz).strftime(fmt)