# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier) - **Date:** 2026-05-31 - **Module:** `fusion_clock` - **Version bump:** `19.0.4.0.3` → `19.0.4.1.0` - **Status:** Approved design, pending implementation plan - **Author:** Claude Code (brainstormed with user) ## 1. Problem Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1 hours of work, and a second break after a higher N2 threshold. Ontario, for example: a 30-minute eating period after 5 hours of work, and (per the user's policy) another 30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every** way an attendance is recorded — including a manager manually adding or editing hours. ### Audit of current behaviour (what exists today) The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are `x_fclk_net_hours = worked_hours − x_fclk_break_minutes/60` (`models/hr_attendance.py:261`). Break minutes are written from **four** places, all implementing variations of one rule: 1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**; reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk (`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours` (default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**), using `max(new, current)` so it doesn't wipe penalty minutes. 2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write. 3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty minutes into the same `x_fclk_break_minutes` field. ### Gaps vs. requirement 1. **Single tier only** — one threshold (4h), one break (30m). No second break. 2. **Not applied on manual entry** — there is **no `create`/`write` override** on `hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`. This is the central gap. 3. **No province/country awareness** — no jurisdiction field exists anywhere (location has address/timezone but no province; company has none). Threshold + amount are flat global config params. 4. **First-break default is 4h, not 5h** (Ontario is 5h). ## 2. Goals / Non-goals **Goals** - Statutory unpaid break applies automatically based on **actual worked hours**, on every path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend create/edit**). - Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is `worked_hours >= N` (inclusive; nothing under N1). - Rules are defined **per province/country** in a table; an employee resolves its rule from its **company's province**, with a single global default fallback. - **Eliminate the duplicated deduction logic** — one calculator, called everywhere. **Non-goals (YAGNI)** - Per-employee break-rule override (resolver is structured so this is a cheap add later). - GPS/location-based jurisdiction detection. - More than two tiers (the table is 2-tier; a 3rd break would be a future schema change). - Changing the *planned* break concept used for scheduled-hours math. ## 3. Locked decisions | # | Decision | Choice | |---|---|---| | 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) | | 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback | | 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save | | 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction | ## 4. Design ### 4.1 New model `fusion.clock.break.rule` `models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`, `_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`. | Field | Type | Default | Notes | |---|---|---|---| | `name` | Char (required) | — | e.g. "Ontario" | | `country_id` | Many2one `res.country` | — | scopes the province picker | | `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` | | `is_default` | Boolean | False | global fallback when no province matches | | `break1_after_hours` | Float | 5.0 | first break trigger N1 | | `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) | | `break2_after_hours` | Float | 10.0 | second break trigger N2 | | `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) | | `sequence` | Integer | 10 | | | `active` | Boolean | True | | **Constraints** (`models.Constraint`, per repo Odoo-19 rule 9): - `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`. - When `break2_minutes > 0`: `break2_after_hours > break1_after_hours` (a misordered second tier is a config error). - (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains` rather than a partial unique index, to give a friendly message. **Method** — `break_minutes_for(self, worked_hours)`: ``` self.ensure_one() total = 0.0 if self.break1_minutes and worked_hours >= self.break1_after_hours: total += self.break1_minutes if self.break2_minutes and worked_hours >= self.break2_after_hours: total += self.break2_minutes return total ``` `>=` is intentional and matches the requirement ("equal to or more than N1"). **Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row — `name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`, `break1_after_hours=5.0`, `break1_minutes=30.0`, `break2_after_hours=10.0`, `break2_minutes=30.0`. (Acting as both the Ontario match and the global fallback for this deployment. Other provinces can be added as rows.) ### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()` ``` self.ensure_one() Rule = self.env['fusion.clock.break.rule'].sudo() state = self.company_id.state_id rule = Rule.browse() if state: rule = Rule.search([('state_id', '=', state.id)], limit=1) if not rule: rule = Rule.search([('is_default', '=', True)], limit=1) return rule # may be empty recordset → caller treats as 0 break ``` `sudo()` so the portal net-hours compute (run as the employee) can read the rule table without a direct ACL grant. Resolver is a single method → adding a per-employee override (`x_fclk_break_rule_id`) later is a two-line change. ### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute The field changes from a plain editable Float to a **stored computed** field — this is the single calculator that replaces all four write sites. ```python x_fclk_break_minutes = fields.Float( string='Break (min)', compute='_compute_fclk_break_minutes', store=True, tracking=True, help="Unpaid break deducted from worked hours: statutory break (by province " "rule, from actual hours worked) plus any penalty minutes.", ) @api.depends('worked_hours', 'check_out', 'x_fclk_penalty_ids.penalty_minutes', 'employee_id') def _compute_fclk_break_minutes(self): ICP = self.env['ir.config_parameter'].sudo() auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True' for att in self: statutory = 0.0 if auto and att.check_out and att.employee_id: rule = att.employee_id._get_fclk_break_rule() if rule: statutory = rule.break_minutes_for(att.worked_hours or 0.0) penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes')) att.x_fclk_break_minutes = statutory + penalties ``` Properties: - **Idempotent** — same hours + same penalties always yield the same value; no drift, nothing to wipe. - **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out` change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute automatically. This is what fixes the manual-entry gap. - **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted). - **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain). - `_compute_net_hours` is unchanged (still `worked_hours − break/60`); it now depends on a computed-stored field, which Odoo chains correctly. The attendance form's Break field becomes read-only (consistent with "fully automatic"). `views/hr_attendance_views.xml` updated accordingly. ### 4.4 Removals (the de-duplication) | Remove | File | Replaced by | |---|---|---| | `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute | | cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute | | penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` | | setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` | **Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`, `fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the **planned** break (used to compute scheduled `planned_hours`), a separate concept from the actual worked-hours deduction. Decision #4 keeps them out of the deduction path. **Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only). ### 4.5 UI / security / data - **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` + list/form views in `views/clock_break_rule_views.xml`), gated to `group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`. - **Security:** `security/ir.model.access.csv` — `fusion.clock.break.rule`: manager = full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant is strictly required; grant manager full, no portal access). - **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons) and `views/clock_break_rule_views.xml` (with the other config views, before `clock_menus.xml`). Bump `version` to `19.0.4.1.0`. ## 5. Edge cases - **No rule resolvable** (no province match, no default) → statutory 0. The seeded default prevents this in practice. - **Company has no `state_id`** → falls to the default rule. - **`break2_after_hours <= break1_after_hours`** → blocked by constraint. - **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute; final break = statutory + penalty (preserves today's combined-field semantics, reported as one "Break" number). - **Open attendance** (no checkout) → break 0; recomputed when it's closed. - **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`). ## 6. Migration / upgrade - On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute it for all existing rows. For closed attendances this re-derives break from `worked_hours` + linked penalties using the seeded Ontario rule — which is the intended corrected value. Any historical hand-edited break values are replaced (acceptable per Decision #3, "fully automatic"). Call this out in the change log. - No `pre`/`post` migration script is required; the recompute is automatic. (If we later want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of scope for now.) ## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`) 1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60. 2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default. 3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30, net = worked − 0.5. **Manual edit** extending to 10h → break 60. (This is the headline gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.) 4. Penalty additivity: 6h + one 15-min penalty record → break 45. 5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only). 6. Constraint: `break2_after_hours <= break1_after_hours` raises. Run (note ephemeral ports per repo CLAUDE.md): ``` docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \ -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 ``` ## 8. Rollout notes - **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock` (Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit from the git path only. (Per project memory.) - Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review. - Asset/version bump already covered by the manifest `version` change. ## 9. Open questions None — all four design forks resolved (see §3).