update
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user