Files
Odoo-Modules/docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md
gsinghpal aa9b95bd5d docs(fusion_clock): spec for province-aware automatic unpaid break
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>
2026-05-31 23:35:58 -04:00

13 KiB
Raw Blame History

Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier)

  • Date: 2026-05-31
  • Module: fusion_clock
  • Version bump: 19.0.4.0.319.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.

Methodbreak_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.attendancex_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 pathworked_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 = 0check_out empty → statutory 0 (penalties, if any, still counted).
  • Master toggle preservedauto_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.csvfusion.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-outx_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).