feat(fusion_clock): schedule-driven attendance automation

Reminders, absence detection, late/early penalties, and auto-clock-out are now
driven by each employee's real schedule (posted planner entry -> recurring
shift), never the global 9-5 default. Employees who aren't scheduled get no
reminders/absence. Overtime past the scheduled end is never cut off — auto
clock-out only fires at a max-shift safety cap (default raised 12 -> 16h). Team
leads build the planner in draft and Post it (publishes + emails employees).

- hr.employee._get_fclk_day_plan: explicit `scheduled` flag; posted-only planner
  entries (drafts ignored), else recurring shift covering that weekday, else
  not-scheduled; sources 'schedule'/'shift'/'none'.
- fusion.clock.shift: day_mon..day_sun weekday pattern + covers_weekday().
- fusion.clock.schedule: draft/posted state + posted_date; planner edits reset
  to draft; fclk_email_posted_week notification.
- Rewrote the reminder / absence / auto-clock-out crons: schedule-gated,
  per-employee savepoints, OT-aware cap, weekend hardcode removed.
- Penalties + all three clock-in paths skip days the employee isn't scheduled.
- shift_planner: Post Week route + planner Post button + draft count.
- Migration backfills pre-existing schedule entries to 'posted' so they keep
  driving automation after upgrade.
- Tests: resolver matrix, cron gating, OT cap; fixed the existing planner test
  for the new state/source semantics.

Design: docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md
Frontend footprint kept at zero to avoid colliding with the concurrent
employee-portal (payslips) work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-30 21:54:05 -04:00
parent b5d5a9acba
commit 2aaa1a57e7
18 changed files with 557 additions and 160 deletions

View File

@@ -2,11 +2,15 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
compute='_compute_display_name',
store=True,
)
state = fields.Selection(
[('draft', 'Draft'), ('posted', 'Posted')],
string='Status',
default='draft',
index=True,
help="Only POSTED entries drive reminders, absence checks and penalties. "
"Draft entries are ignored by automation until the team lead posts them.",
)
posted_date = fields.Datetime(string='Posted On', readonly=True)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
# Any planner edit returns the cell to draft; it must be re-posted
# before automation acts on it.
'state': 'draft',
'posted_date': False,
}
if existing:
existing.write(vals)
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
return {
'schedule_id': schedule.id,
'source': 'schedule',
'state': schedule.state,
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'source': plan.get('source') or 'none',
'state': False,
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
'note': '',
}
@api.model
def fclk_email_posted_week(self, employee, week_start, week_end):
"""Email one employee a summary of their POSTED shifts for the week."""
employee = employee.sudo()
if not employee.work_email:
return False
from .hr_attendance import _fclk_email_wrap
entries = self.sudo().search([
('employee_id', '=', employee.id),
('schedule_date', '>=', week_start),
('schedule_date', '<=', week_end),
('state', '=', 'posted'),
])
by_date = {entry.schedule_date: entry for entry in entries}
rows = []
day = week_start
while day <= week_end:
entry = by_date.get(day)
rows.append((
day.strftime('%a %b %d'),
entry.fclk_display_value() if entry else 'Not scheduled',
))
day += timedelta(days=1)
company = employee.company_id or self.env.company
body = _fclk_email_wrap(
company_name=company.name or '',
title='Your Posted Schedule',
summary=(
f'Hello <strong>{employee.name}</strong>, your shifts for '
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
f'have been posted.'
),
sections=[('This Week', rows)],
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
)
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
'email_from': company.email or '',
'email_to': employee.work_email,
'body_html': body,
'auto_delete': True,
})
mail.send()
return True
except Exception as exc:
_logger.error(
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
)
return False
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'