This commit is contained in:
gsinghpal
2026-02-23 00:32:20 -05:00
parent d6bac8e623
commit e8e554de95
549 changed files with 1330 additions and 124935 deletions

View File

@@ -254,126 +254,6 @@ class FusionClockReport(models.Model):
except Exception as e:
_logger.error("Fusion Clock: Error generating batch report: %s", e)
@api.model
def _backfill_historical_reports(self):
"""Create reports for all past pay periods that have attendance data
but no corresponding report. Does NOT send emails.
Called from settings button or post_init_hook on install.
"""
HrAtt = self.env['hr.attendance']
today = fields.Date.today()
# Get all distinct periods that have completed attendance records
all_atts = HrAtt.search([
('check_out', '!=', False),
('x_fclk_pay_period_start', '!=', False),
], order='x_fclk_pay_period_start')
# Group by (period_start, pay_period_label) to find distinct periods
period_map = {}
for att in all_atts:
key = att.x_fclk_pay_period_start
if key and key not in period_map:
period_map[key] = att.x_fclk_pay_period
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
created_count = 0
for period_start_date, period_label in period_map.items():
# Calculate period end from the start date
_, period_end = HrAtt._calc_period(
schedule_type, anchor, period_start_date,
)
# Skip the current (incomplete) period
if period_end >= today:
continue
# Find all employees with attendance in this period
period_atts = HrAtt.search([
('check_out', '!=', False),
('x_fclk_pay_period_start', '=', period_start_date),
])
employee_ids = period_atts.mapped('employee_id')
for employee in employee_ids:
# Check if report already exists
existing = self.search([
('employee_id', '=', employee.id),
('date_start', '=', period_start_date),
('date_end', '=', period_end),
], limit=1)
if existing:
continue
emp_atts = period_atts.filtered(
lambda a, e=employee: a.employee_id == e
)
report = self.create({
'date_start': period_start_date,
'date_end': period_end,
'schedule_type': schedule_type,
'employee_id': employee.id,
'company_id': employee.company_id.id,
'attendance_ids': [(6, 0, emp_atts.ids)],
})
try:
report._generate_pdf()
report.state = 'generated'
created_count += 1
except Exception as e:
_logger.warning(
"Fusion Clock backfill: PDF failed for %s period %s: %s",
employee.name, period_label, e,
)
report.state = 'generated'
created_count += 1
# Batch report for this period
existing_batch = self.search([
('employee_id', '=', False),
('date_start', '=', period_start_date),
('date_end', '=', period_end),
], limit=1)
if not existing_batch and period_atts:
company = period_atts[0].employee_id.company_id
batch = self.create({
'date_start': period_start_date,
'date_end': period_end,
'schedule_type': schedule_type,
'employee_id': False,
'company_id': company.id,
'attendance_ids': [(6, 0, period_atts.ids)],
})
try:
batch._generate_pdf()
batch.state = 'generated'
created_count += 1
except Exception as e:
_logger.warning(
"Fusion Clock backfill: batch PDF failed for %s: %s",
period_label, e,
)
batch.state = 'generated'
created_count += 1
# Commit after each period to avoid losing everything on error
self.env.cr.commit() # pylint: disable=invalid-commit
_logger.info(
"Fusion Clock: Backfill complete. Created %d reports.", created_count,
)
return created_count
@api.model
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
"""Calculate the period start/end dates based on schedule type."""

View File

@@ -15,20 +15,13 @@ class HrAttendance(models.Model):
x_fclk_location_id = fields.Many2one(
'fusion.clock.location',
string='Clock-In Location',
string='Clock 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'),
@@ -73,90 +66,6 @@ class HrAttendance(models.Model):
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:
@@ -164,93 +73,6 @@ class HrAttendance(models.Model):
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.

View File

@@ -2,7 +2,6 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo import models, fields, api
@@ -24,16 +23,6 @@ class HrEmployee(models.Model):
default=0.0,
help="Override default break duration for this employee. 0 = use company default.",
)
x_fclk_period_hours = fields.Float(
string='Period Hours',
compute='_compute_fclk_period_hours',
help="Net hours worked in the current pay period.",
)
x_fclk_period_label = fields.Char(
string='Current Period',
compute='_compute_fclk_period_hours',
help="Label for the current pay period.",
)
def _get_fclk_break_minutes(self):
"""Return effective break minutes for this employee."""
@@ -45,56 +34,3 @@ class HrEmployee(models.Model):
'fusion_clock.default_break_minutes', '30'
)
)
def action_fclk_open_attendance(self):
"""Open attendance list for this employee, grouped by pay period."""
self.ensure_one()
list_view = self.env.ref('fusion_clock.view_hr_attendance_list_by_period')
search_view = self.env.ref('fusion_clock.view_hr_attendance_search_by_period')
return {
'type': 'ir.actions.act_window',
'name': f'{self.name} - Attendance',
'res_model': 'hr.attendance',
'view_mode': 'list,form',
'views': [(list_view.id, 'list'), (False, 'form')],
'search_view_id': (search_view.id, search_view.name),
'domain': [('employee_id', '=', self.id)],
'context': {
'search_default_group_pay_period': 1,
'default_employee_id': self.id,
},
}
def _compute_fclk_period_hours(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', '')
anchor = None
if anchor_str:
try:
anchor = fields.Date.from_string(anchor_str)
except Exception:
pass
today = fields.Date.today()
Attendance = self.env['hr.attendance']
period_start, period_end = Attendance._calc_period(schedule_type, anchor, today)
period_label = (
f"{period_start.strftime('%b %d')} - "
f"{period_end.strftime('%b %d, %Y')}"
)
start_dt = datetime.combine(period_start, datetime.min.time())
end_dt = datetime.combine(period_end + timedelta(days=1), datetime.min.time())
for emp in self:
atts = Attendance.sudo().search([
('employee_id', '=', emp.id),
('check_in', '>=', start_dt),
('check_in', '<', end_dt),
('check_out', '!=', False),
])
emp.x_fclk_period_hours = round(
sum(a.x_fclk_net_hours or 0 for a in atts), 1
)
emp.x_fclk_period_label = period_label

View File

@@ -2,7 +2,6 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta
from odoo import models, fields, api
@@ -86,19 +85,11 @@ class ResConfigSettings(models.TransientModel):
string='Pay Period',
config_parameter='fusion_clock.pay_period_type',
default='biweekly',
help="How often pay periods repeat. Semi-Monthly uses 1st-15th and 16th-end; "
"Weekly and Bi-Weekly use the anchor date below.",
)
fclk_pay_period_start = fields.Date(
fclk_pay_period_start = fields.Char(
string='Pay Period Anchor Date',
help="The first day of any real pay period. All periods are calculated "
"forward and backward from this date. For example, if your biweekly "
"pay period runs Jan 17 - Jan 30, set this to Jan 17.",
)
fclk_pay_period_preview = fields.Char(
string='Current Period Preview',
compute='_compute_pay_period_preview',
help="Shows the current pay period based on today's date and your settings.",
config_parameter='fusion_clock.pay_period_start',
help="Start date for pay period calculations (YYYY-MM-DD format, anchor for weekly/biweekly).",
)
# -- Reports --
@@ -131,85 +122,3 @@ class ResConfigSettings(models.TransientModel):
config_parameter='fusion_clock.enable_sounds',
default=True,
)
@api.depends('fclk_pay_period_type', 'fclk_pay_period_start')
def _compute_pay_period_preview(self):
HrAtt = self.env['hr.attendance']
today = fields.Date.context_today(self)
for rec in self:
schedule = rec.fclk_pay_period_type or 'biweekly'
anchor = rec.fclk_pay_period_start
if not anchor and schedule in ('weekly', 'biweekly'):
rec.fclk_pay_period_preview = 'Set anchor date to see preview'
continue
period_start, period_end = HrAtt._calc_period(schedule, anchor, today)
rec.fclk_pay_period_preview = (
f"{period_start.strftime('%b %d, %Y')} - "
f"{period_end.strftime('%b %d, %Y')}"
)
def action_backfill_reports(self):
"""Generate reports for all historical pay periods without sending email."""
count = self.env['fusion.clock.report'].sudo()._backfill_historical_reports()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Historical Reports',
'message': f'Created {count} reports for past pay periods.',
'type': 'success',
'sticky': False,
},
}
def action_backfill_breaks(self):
"""Apply break deduction to all past attendance records missing it,
then regenerate any existing report PDFs so they reflect the new totals."""
count = self.env['hr.attendance'].sudo().action_backfill_breaks()
# Regenerate existing report PDFs so stored files match updated totals
if count:
reports = self.env['fusion.clock.report'].sudo().search([
('state', 'in', ['generated', 'sent']),
])
for report in reports:
try:
report._generate_pdf()
except Exception:
pass
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Break Backfill',
'message': f'Applied break deduction to {count} attendance records. Report PDFs regenerated.',
'type': 'success',
'sticky': False,
},
}
@api.model
def get_values(self):
res = super().get_values()
ICP = self.env['ir.config_parameter'].sudo()
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
if anchor_str:
try:
res['fclk_pay_period_start'] = fields.Date.from_string(anchor_str)
except Exception:
res['fclk_pay_period_start'] = False
return res
def set_values(self):
super().set_values()
ICP = self.env['ir.config_parameter'].sudo()
val = self.fclk_pay_period_start
ICP.set_param(
'fusion_clock.pay_period_start',
fields.Date.to_string(val) if val else '',
)
# Recompute all pay periods so existing records match current settings
self.env['hr.attendance'].sudo().search([
('check_in', '!=', False),
])._compute_pay_period()