2-tier statutory break deduction: new fusion.clock.break.rule per-province table (seed Ontario 5h/30 + 10h/30); x_fclk_break_minutes becomes an idempotent stored compute (statutory + penalties) firing on every path incl. manual backend entry; collapses the 4 duplicated break-write sites into one calculator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
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:
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 toemployee._get_fclk_break_minutes()(default 30), usingmax(new, current)so it doesn't wipe penalty minutes.- Auto-clock-out cron (
models/hr_attendance.py:343) — same single-threshold write. controllers/clock_api.py::_check_and_create_penalty(line 140) — adds penalty minutes into the samex_fclk_break_minutesfield.
Gaps vs. requirement
- Single tier only — one threshold (4h), one break (30m). No second break.
- Not applied on manual entry — there is no
create/writeoverride onhr.attendance. A manager-created or manager-edited attendance gets break= 0. This is the central gap. - 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.
- 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.constrainsrather 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.
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_hoursrecomputes whenevercheck_in/check_outchange, 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_outempty → statutory 0 (penalties, if any, still counted). - Master toggle preserved —
auto_deduct_breakFalse → statutory 0 (penalties remain). _compute_net_hoursis unchanged (stillworked_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 inviews/clock_break_rule_views.xml), gated togroup_fusion_clock_manager. Add the menu item inviews/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: adddata/clock_break_rule_data.xml(after security, before crons) andviews/clock_break_rule_views.xml(with the other config views, beforeclock_menus.xml). Bumpversionto19.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_idschange 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_minutestostore=True computemakes Odoo recompute it for all existing rows. For closed attendances this re-derives break fromworked_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/postmigration 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'))
break_minutes_for: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60.- Resolver: company in Ontario → Ontario rule; company with unset/other province → default.
- 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.) - Penalty additivity: 6h + one 15-min penalty record → break 45.
- Master toggle off (
auto_deduct_break=False) → statutory 0 (penalty-only). - Constraint:
break2_after_hours <= break1_after_hoursraises.
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) andK:\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
versionchange.
9. Open questions
None — all four design forks resolved (see §3).