Files
Odoo-Modules/docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md
gsinghpal dd908c3861 docs(fusion_clock): design spec — schedule-driven attendance automation
Posted-schedule/recurring-shift drives reminders, absence, penalties, and
auto-clock-out (never the global 9-5 default); overtime never cut (auto-close
only at a safety cap); team-lead draft->post workflow with employee notify.
Frontend footprint kept at zero to avoid colliding with the concurrent
fusion_plating employee-portal session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:17:56 -04:00

12 KiB
Raw Blame History

Schedule-Driven Attendance Automation — Design

Date: 2026-05-30 Module: fusion_clock Status: Approved design → ready for implementation plan

Goal

Drive every attendance automation (clock-in/out reminders, absence detection, late/early penalties, auto-clock-out) from each employee's real schedule — the team lead's posted planner entry first, then the employee's recurring shift — never the global 95 default. Employees who aren't scheduled get no reminders or absence flags. Overtime past the scheduled end is normal and is never cut off.

Problem & root cause

The machinery already exists: fusion.clock.shift (recurring templates, assigned via hr.employee.x_fclk_shift_id), fusion.clock.schedule (dated per-employee entries built in the backend shift planner client action), and hr.employee._get_fclk_day_plan(date) which resolves per-day times. The crons already call these.

The bug: in _get_fclk_day_plan(), when an employee has no dated entry and no assigned shift, it silently falls back to the global 95 default with is_off = False. So everyone is treated as a 95 worker, and the reminder / absence crons fire off that global time. The crons also hardcode-skip Sat/Sun (weekday() >= 5), which is wrong for a production floor that runs weekends. Net effect: reminders are not actually schedule-driven for anyone who isn't on a fixed weekday 95 — exactly the spurious-email problem reported.

Decisions (from brainstorming)

  1. "Expected to work" source: posted planner entry → else recurring shift (if it covers that weekday) → else not scheduled (silent). The global default never makes someone "expected."
  2. Overtime: time past the scheduled end is overtime and is never cut off. Auto-clock-out fires only at a generous safety cap (forgot-to-clock-out).
  3. Posting: draft → post gate. Team leads build the week in draft; automation ignores draft days. "Post" publishes the week and emails each employee their shifts. Only posted entries drive automation.
  4. Employee schedule view: reuse the existing "Today's Shift" card on /my/clock — no new portal view. (See Coordination.)

Non-goals / constraints

  • No edits to the employee /my portal shell. A concurrent session ("Internal employee portal design", fusion_plating) owns /my + /my/home routing and the /my/clock bottom-nav tabs (it is adding a Payslips tab). This feature makes zero edits to controllers/portal_clock.py routing, views/portal_clock_templates.xml, or /my routing. The existing "Today's Shift" card already renders today_schedule.get('label') or 'Not scheduled', so once the resolver is schedule-driven the card updates itself. Employees get their full posted week via the Post notification email. A dedicated "My Schedule" nav tab, if ever wanted, belongs to the portal-shell session.
  • The backend shift planner client action (manager/team-lead facing) is not the /my portal and is in scope to edit (Post button, draft/posted visuals).
  • No change to how attendance hours / overtime are computed.

Architecture

1. Schedule resolver — hr.employee._get_fclk_day_plan(date)

Rewrite to return an explicit scheduled flag and a precise source, keeping all existing keys for backward compatibility (is_off, label, hours, start_time, end_time, break_minutes).

Return shape:

{
  'scheduled': bool,            # is the employee expected to work this day?
  'source': 'schedule' | 'shift' | 'none',
  'is_off': bool,
  'start_time': float, 'end_time': float, 'break_minutes': float,
  'hours': float,
  'label': str,                 # '' when not scheduled → card shows 'Not scheduled'
  'schedule_id': int | False,
}

Resolution order:

  1. Posted planner entry (fusion.clock.schedule, state == 'posted') for (employee, date) — draft entries are ignored, treated as absent:
    • is_offscheduled=False, is_off=True, source='schedule', hours=0, label='OFF'.
    • else → scheduled=True, times from entry, source='schedule'.
  2. Else recurring shift x_fclk_shift_id and the shift covers date's weekday → scheduled=True, times from shift, source='shift'.
  3. Else → scheduled=False, source='none', is_off=False, label='', hours=0. (Global default may fill start_time/end_time as a display hint only; it never sets scheduled=True.)

_get_fclk_scheduled_times() and _get_fclk_break_minutes() keep working off this structure unchanged.

2. Data model changes

  • fusion.clock.schedule: add
    • state = Selection([('draft','Draft'),('posted','Posted')], default='draft')
    • posted_date = Datetime
    • Automation reads only state == 'posted'.
  • fusion.clock.shift: add a weekday pattern — day_mon … day_sun = Boolean (default MonFri True, SatSun False) plus a helper covers_weekday(date) -> bool. This replaces the hardcoded weekend skip and lets weekend shifts exist. (Judgment call: pattern lives on the shared shift template, e.g. "MonFri Day", "SatSun Weekend"; unique patterns → own template or a posted planner override.)

3. Posting workflow

  • New jsonrpc route POST /fusion_clock/shift_planner/post_week in controllers/shift_planner.py:
    • Gate: manager OR team lead.
    • Scope: managers → all in-scope employees for the viewed week; team leads → their direct reports (parent_id == the team lead's employee). Reuse the existing dashboard scoping helper.
    • Set state='posted', posted_date=now on those week entries.
    • Queue one email per affected employee summarizing their posted shifts for the week (reuse _fclk_email_wrap). Failures logged, never block the post.
  • New planner entries default to draft. Re-posting after edits re-publishes (and re-notifies, flagged as an update).
  • Planner client action (static/src/js/fusion_clock_shift_planner.js + its template) gains a Post button and a draft-vs-posted visual cue. (Backend client action — not the /my portal.)

4. Reminder cron — hr.attendance._cron_fusion_employee_reminders

  • Remove the weekday() >= 5 hardcode.
  • Per enabled employee: plan = emp._get_fclk_day_plan(today); if not plan['scheduled'] → skip (silent).
  • Missed clock-in: if scheduled, not checked in, no attendance today, and now > scheduled_in + reminder_before_shift_minutes → remind. Uses the employee's real start, so a 14:00 shift is never pinged at 09:30.
  • Clock-out reminder: reframed (judgment call). Drop the "your shift ends at X" nudge (noise when OT is the norm). Instead, if still checked in and approaching the safety cap (check_in + max_shift_hours - reminder_before_end_minutes), send "you're still clocked in — remember to clock out."

5. Absence cron — hr.attendance._cron_fusion_check_absences

  • Remove the weekday() >= 5 hardcode.
  • Per enabled employee: plan = emp._get_fclk_day_plan(yesterday); only flag absent if plan['scheduled'] AND no attendance AND no leave request AND no global holiday. Off/unscheduled → never flagged.

6. Auto-clock-out — hr.attendance._cron_fusion_auto_clock_out

  • Stop closing at scheduled_out + grace. Close only at the safety cap check_in + max_shift_hours. Everything between the scheduled end and the cap is captured as overtime by the existing fields.
  • Bump default max_shift_hours 12 → 16 (still configurable).
  • Keep x_fclk_pending_reason=True, break deduction, and office notify on auto-close.

7. Penalties — controllers/clock_api.py::_check_and_create_penalty

  • Skip when the day is not scheduled (not plan['scheduled']), in addition to the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved scheduled start/end. Overtime is never penalized.

8. Kiosk callers — clock_kiosk.py, clock_nfc_kiosk.py

  • The existing is_scheduled_off = source == 'schedule' and is_off checks keep working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip to also cover source == 'none' (clocked in on a day with no schedule) so a not-scheduled clock-in is logged as unscheduled_shift and creates no penalty.

9. Settings

  • res_config_settings: change fclk_max_shift_hours default 12 → 16 (and the resolver/cron get_param fallback). Optionally surface the shift weekday pattern on the shift form. No other new settings required.

10. Frontend

  • No file edits. The existing "Today's Shift" card auto-reflects the new resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled → "Not scheduled" (already coded as label or 'Not scheduled').

Data flow

posted planner entry / recurring shift → _get_fclk_day_plan(date)scheduled flag → consumed by: reminder cron, absence cron, penalty helper, kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting flips state to posted (making entries visible to the resolver) and emails employees.

Error handling

  • Crons: wrap each employee's body in with self.env.cr.savepoint(): so one bad record can't abort the batch (savepoints, not cr.commit() — works in prod and tests).
  • Posting: state writes + email queueing in one transaction; email creation in try/except with logging so a bad address never blocks the post.
  • Notifications: mail.mail with auto_delete=True; send failures logged.

Testing (tests/test_schedule_driven.py, post_install)

  • Resolver matrix: posted-working / posted-off / draft-ignored / recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled. Assert scheduled, times, and label.
  • Reminder cron: scheduled + late + no attendance → reminder; not scheduled → none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in reminder.
  • Absence cron: scheduled no-show → absent logged; not scheduled → not flagged; leave/holiday → not flagged.
  • Auto-clock-out: open past scheduled end but under cap → stays open; past cap → closed + x_fclk_pending_reason.
  • Posting: draft entry → resolver scheduled=False (ignored by crons); post → state='posted', resolver picks it up, email queued; team lead can post only direct reports.
  • Penalties: not-scheduled clock-in → no penalty; scheduled late → late_in.

Files expected to change (for the plan)

  • models/hr_employee.py — resolver refactor.
  • models/clock_shift.py — weekday booleans + covers_weekday.
  • models/clock_schedule.pystate + posted_date.
  • models/hr_attendance.py — reminders, absences, auto-clock-out + savepoints.
  • controllers/clock_api.py — penalty skip when not scheduled.
  • controllers/clock_kiosk.py, controllers/clock_nfc_kiosk.py — unscheduled log/penalty for source == 'none'.
  • controllers/shift_planner.pypost_week route + scope + notifications; default new entries to draft.
  • static/src/js/fusion_clock_shift_planner.js + planner template — Post button, draft/posted visuals.
  • models/res_config_settings.py + views/res_config_settings_views.xmlmax_shift_hours default 16; optional weekday-pattern surfacing.
  • views/clock_shift_views.xml — weekday checkboxes on the shift form.
  • views/clock_schedule_views.xml — show state.
  • tests/test_schedule_driven.py (+ tests/__init__.py).
  • Not touched: controllers/portal_clock.py routing, views/portal_clock_templates.xml, /my routing (owned by the concurrent portal-shell session).

Coordination

Concurrent session "Internal employee portal design" (fusion_plating) owns the employee /my portal shell: /my + /my/home redirect to the clock page and new bottom-nav tabs (Payslips). This feature is backend-only on the frontend side — it edits no /my portal files — so the two land without conflict regardless of order. Shared touchpoint to watch: both evolve the employee experience; if a "My Schedule" nav tab is desired, it is the portal-shell session's responsibility, fed by this feature's resolver.