# 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 9–5 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 9–5 default with `is_off = False`**. So everyone is treated as a 9–5 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 9–5 — 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: ```python { '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_off` → `scheduled=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 Mon–Fri True, Sat–Sun 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. "Mon–Fri Day", "Sat–Sun 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.py` — `state` + `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.py` — `post_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.xml` — `max_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.