From dd908c3861f41006a172afe21e16381dd2baf56a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 30 May 2026 21:17:56 -0400 Subject: [PATCH] =?UTF-8?q?docs(fusion=5Fclock):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20schedule-driven=20attendance=20automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...05-30-schedule-driven-attendance-design.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md diff --git a/docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md b/docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md new file mode 100644 index 00000000..d8d908e0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md @@ -0,0 +1,247 @@ +# 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.