diff --git a/docs/superpowers/plans/2026-05-31-fusion-clock-statutory-break.md b/docs/superpowers/plans/2026-05-31-fusion-clock-statutory-break.md new file mode 100644 index 00000000..df98de48 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-fusion-clock-statutory-break.md @@ -0,0 +1,854 @@ +# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic. + +**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write). + +**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`). + +**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md` + +--- + +## Dev environment & sync (READ FIRST — applies to every task) + +**Two working copies (per project memory `feedback_dual_path_fusion_clock`):** +- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock` +- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock` + +Edit in the **git tree**, then **mirror to the Docker tree before every test run**: + +```powershell +robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" } +``` +(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing. + +**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`). + +**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md): + +- Run this module's tests: + ```bash + 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 -100 + ``` +- Plain upgrade (no tests): + ```bash + docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50 + ``` +- Pyflakes a changed Python file (catches undefined names instantly): + ```bash + docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/.py + ``` + +**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`. + +--- + +## File Structure + +**Created:** +- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints. +- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`). +- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule. +- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break. +- `fusion_clock/tests/test_break_rules.py` — all new tests. + +**Modified:** +- `fusion_clock/models/__init__.py` — import the new model. +- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`. +- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write. +- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write. +- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call. +- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call. +- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`. +- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules. +- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record. +- `fusion_clock/security/ir.model.access.csv` — manager access for the new model. +- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu. +- `fusion_clock/__manifest__.py` — version bump + new data/view files. +- `fusion_clock/tests/__init__.py` — import the new test module. +- `fusion_clock/tests/test_settings.py` — assert the retired field is gone. +- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5). + +**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before. + +--- + +## Task 1: New model `fusion.clock.break.rule` + +**Files:** +- Create: `fusion_clock/models/clock_break_rule.py` +- Create: `fusion_clock/data/clock_break_rule_data.xml` +- Create: `fusion_clock/views/clock_break_rule_views.xml` +- Create: `fusion_clock/tests/test_break_rules.py` +- Modify: `fusion_clock/models/__init__.py` +- Modify: `fusion_clock/tests/__init__.py` +- Modify: `fusion_clock/security/ir.model.access.csv` +- Modify: `fusion_clock/views/clock_menus.xml` +- Modify: `fusion_clock/__manifest__.py` + +- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import datetime, timedelta +from odoo.tests import tagged, TransactionCase +from odoo.exceptions import ValidationError + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestBreakRules(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ICP = cls.env['ir.config_parameter'].sudo() + cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True') + cls.Rule = cls.env['fusion.clock.break.rule'] + cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1) + cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'}) + + def _mk_att(self, hours): + check_in = datetime(2026, 1, 5, 9, 0, 0) + return self.env['hr.attendance'].create({ + 'employee_id': self.employee.id, + 'check_in': check_in, + 'check_out': check_in + timedelta(hours=hours), + }) + + # ---- Task 1: tier engine + constraints ---- + def test_break_minutes_for_tiers(self): + rule = self.Rule.create({ + 'name': 'Tier Test', 'is_default': False, + 'break1_after_hours': 5.0, 'break1_minutes': 30.0, + 'break2_after_hours': 10.0, 'break2_minutes': 30.0, + }) + self.assertEqual(rule.break_minutes_for(4.99), 0.0) + self.assertEqual(rule.break_minutes_for(5.0), 30.0) + self.assertEqual(rule.break_minutes_for(9.99), 30.0) + self.assertEqual(rule.break_minutes_for(10.0), 60.0) + self.assertEqual(rule.break_minutes_for(12.0), 60.0) + + def test_second_tier_must_exceed_first(self): + with self.assertRaises(ValidationError): + self.Rule.create({ + 'name': 'Bad', 'is_default': False, + 'break1_after_hours': 5.0, 'break1_minutes': 30.0, + 'break2_after_hours': 5.0, 'break2_minutes': 30.0, + }) + + def test_single_default_enforced(self): + self.assertTrue(self.default_rule, "seed default rule must exist") + with self.assertRaises(ValidationError): + self.Rule.create({ + 'name': 'Another Default', 'is_default': True, 'active': True, + 'break1_after_hours': 5.0, 'break1_minutes': 30.0, + 'break2_after_hours': 10.0, 'break2_minutes': 30.0, + }) +``` + + Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present): + +```python +from . import test_break_rules +``` + +- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + + +class FusionClockBreakRule(models.Model): + _name = 'fusion.clock.break.rule' + _description = 'Statutory Break Rule' + _order = 'sequence, name' + + name = fields.Char(string='Name', required=True) + country_id = fields.Many2one('res.country', string='Country') + state_id = fields.Many2one( + 'res.country.state', + string='Province / State', + help="Employees whose company is in this province use this rule.", + ) + is_default = fields.Boolean( + string='Default Rule', + help="Used when an employee's company province matches no other rule. " + "Only one active rule may be the default.", + ) + break1_after_hours = fields.Float( + string='First Break After (h)', default=5.0, + help="Worked hours at or above this trigger the first unpaid break.", + ) + break1_minutes = fields.Float( + string='First Break (min)', default=30.0, + help="Length of the first unpaid break. 0 disables it.", + ) + break2_after_hours = fields.Float( + string='Second Break After (h)', default=10.0, + help="Worked hours at or above this add the second unpaid break.", + ) + break2_minutes = fields.Float( + string='Second Break (min)', default=30.0, + help="Length of the second unpaid break. 0 disables it.", + ) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + + def break_minutes_for(self, worked_hours): + """Total statutory unpaid break (minutes) for the given worked hours. + + Tiers are inclusive (``>=``): a break applies when worked hours are + equal to or greater than the threshold. The second tier adds on top of + the first. + """ + self.ensure_one() + worked = worked_hours or 0.0 + total = 0.0 + if self.break1_minutes and worked >= self.break1_after_hours: + total += self.break1_minutes + if self.break2_minutes and worked >= self.break2_after_hours: + total += self.break2_minutes + return total + + @api.constrains('break1_after_hours', 'break1_minutes', + 'break2_after_hours', 'break2_minutes') + def _check_tiers(self): + for rule in self: + if min(rule.break1_after_hours, rule.break1_minutes, + rule.break2_after_hours, rule.break2_minutes) < 0: + raise ValidationError(_("Break hours and minutes cannot be negative.")) + if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours: + raise ValidationError(_( + "The second break threshold (%(n2)s h) must be greater than " + "the first (%(n1)s h).", + n2=rule.break2_after_hours, n1=rule.break1_after_hours)) + + @api.constrains('is_default', 'active') + def _check_single_default(self): + for rule in self: + if rule.is_default and rule.active: + dupe = self.search([ + ('is_default', '=', True), ('active', '=', True), + ('id', '!=', rule.id), + ], limit=1) + if dupe: + raise ValidationError(_( + "Only one active break rule can be the default " + "(currently: %s).", dupe.name)) +``` + +- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import: + +```python +from . import clock_break_rule +``` + +- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`: + +``` +access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1 +``` + +(No user/portal grant needed — the resolver reads the table via `sudo()`.) + +- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`: + +```xml + + + + Ontario + + + + 5.0 + 30.0 + 10.0 + 30.0 + + +``` + +- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`: + +```xml + + + + fusion.clock.break.rule.list + fusion.clock.break.rule + + + + + + + + + + + + + + + + + + fusion.clock.break.rule.form + fusion.clock.break.rule + +
+ + +
+

+
+ + + + + + + + + + +

+ Breaks are unpaid and deducted from actual worked hours. A tier with + 0 minutes is disabled. Triggers are inclusive — a break applies when + worked hours are equal to or above the threshold. +

+
+
+
+
+ + + Break Rules + fusion.clock.break.rule + list,form + {'active_test': False} + +

Create a statutory break rule

+

Define unpaid meal-break thresholds per province/country. Employees inherit + the rule matching their company's province, or the default rule.

+
+
+
+``` + +- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`: + +```xml + +``` + +- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`: + + Change the version: +```python + 'version': '19.0.4.1.0', +``` + Add the seed data file after `'data/ir_config_parameter_data.xml',`: +```python + 'data/clock_break_rule_data.xml', +``` + Add the view file after `'views/clock_schedule_views.xml',`: +```python + 'views/clock_break_rule_views.xml', +``` + +- [ ] **Step 9: Sync, upgrade, run tests** + + Sync (see preamble), then: +```bash +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 -100 +``` + Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.) + +- [ ] **Step 10: Commit** + +```bash +git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py +git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +git -C "K:/Github/Odoo-Modules" push +``` + +--- + +## Task 2: Jurisdiction resolver on `hr.employee` + +**Files:** +- Modify: `fusion_clock/models/hr_employee.py` +- Modify: `fusion_clock/tests/test_break_rules.py` + +- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`: + +```python + # ---- Task 2: jurisdiction resolver ---- + def test_resolver_matches_company_province(self): + bc = self.env.ref('base.state_ca_bc') + bc_rule = self.Rule.create({ + 'name': 'British Columbia', 'state_id': bc.id, 'is_default': False, + 'break1_after_hours': 5.0, 'break1_minutes': 30.0, + 'break2_after_hours': 10.0, 'break2_minutes': 30.0, + }) + self.employee.company_id.state_id = bc.id + self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule) + + def test_resolver_falls_back_to_default(self): + self.assertTrue(self.default_rule, "seed default rule must exist") + alberta = self.env.ref('base.state_ca_ab') # no rule for AB + self.employee.company_id.state_id = alberta.id + self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule) +``` + +- [ ] **Step 2: Run to verify they fail** + + Sync, then: +```bash +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 +``` + Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`. + +- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`): + +```python + def _get_fclk_break_rule(self): + """Return the statutory break rule for this employee. + + Resolution: company's province → matching rule; else the global default + rule; else an empty recordset (caller treats as zero break). Read via + sudo so the portal net-hours compute can resolve it without a direct ACL. + """ + self.ensure_one() + Rule = self.env['fusion.clock.break.rule'].sudo() + rule = Rule.browse() + state = self.company_id.state_id + if state: + rule = Rule.search([('state_id', '=', state.id)], limit=1) + if not rule: + rule = Rule.search([('is_default', '=', True)], limit=1) + return rule +``` + +- [ ] **Step 4: Run to verify they pass** + + Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py +git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +git -C "K:/Github/Odoo-Modules" push +``` + +--- + +## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes + +This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together. + +**Files:** +- Modify: `fusion_clock/models/hr_attendance.py` +- Modify: `fusion_clock/controllers/clock_api.py` +- Modify: `fusion_clock/controllers/clock_kiosk.py` +- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py` +- Modify: `fusion_clock/tests/test_break_rules.py` + +- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`: + +```python + # ---- Task 3: automatic deduction on every path ---- + def test_manual_attendance_applies_statutory_break(self): + att = self._mk_att(6) # 6h >= 5 -> first break + self.assertEqual(att.x_fclk_break_minutes, 30.0) + self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2) + + def test_manual_edit_extends_break(self): + att = self._mk_att(6) + self.assertEqual(att.x_fclk_break_minutes, 30.0) + att.check_out = att.check_in + timedelta(hours=10) # now >= 10 + self.assertEqual(att.x_fclk_break_minutes, 60.0) + self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2) + + def test_under_first_threshold_no_break(self): + att = self._mk_att(4) # 4h < 5 -> nothing + self.assertEqual(att.x_fclk_break_minutes, 0.0) + self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2) + + def test_penalty_minutes_are_additive(self): + att = self._mk_att(6) # statutory 30 + self.env['fusion.clock.penalty'].create({ + 'attendance_id': att.id, + 'employee_id': self.employee.id, + 'penalty_type': 'early_out', + 'penalty_minutes': 15.0, + 'date': att.check_in.date(), + }) + self.assertEqual(att.x_fclk_break_minutes, 45.0) + + def test_master_toggle_off_zero_statutory(self): + self.ICP.set_param('fusion_clock.auto_deduct_break', 'False') + att = self._mk_att(6) + self.assertEqual(att.x_fclk_break_minutes, 0.0) + + def test_open_attendance_zero_break(self): + att = self.env['hr.attendance'].create({ + 'employee_id': self.employee.id, + 'check_in': datetime(2026, 1, 5, 9, 0, 0), + }) + self.assertEqual(att.x_fclk_break_minutes, 0.0) +``` + +- [ ] **Step 2: Run to verify they fail** + + Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet). + +- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition: + + OLD: +```python + x_fclk_break_minutes = fields.Float( + string='Break (min)', + default=0.0, + tracking=True, + help="Break duration in minutes to deduct from worked hours.", + ) +``` + NEW: +```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 (per the " + "employee's province rule, from actual hours worked) plus any penalty " + "minutes. Computed automatically on every save.", + ) +``` + +- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator): + +```python + @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 +``` + +- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`: + + Remove the now-unused threshold read (the line near the top of the method): +```python + threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) +``` + Remove the two now-unused locals in the per-attendance loop: +```python + emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC') + check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date() +``` + Remove the break-write block (the compute now applies the break when `check_out` is set): +```python + if (att.worked_hours or 0) >= threshold: + att.sudo().write( + {'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)} + ) +``` + (Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.) + +- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`: + + Delete the entire `_apply_break_deduction` method: +```python + def _apply_break_deduction(self, attendance, employee): + """Apply automatic break deduction if configured.""" + ICP = request.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True': + return + + threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) + worked = attendance.worked_hours or 0.0 + + if worked >= threshold: + local_date = get_local_today(request.env, employee) + if attendance.check_in: + tz_name = ( + employee.resource_id.tz + or (employee.user_id.partner_id.tz if employee.user_id else False) + or employee.company_id.partner_id.tz + or 'UTC' + ) + local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date() + break_min = employee._get_fclk_break_minutes(local_date) + current = attendance.x_fclk_break_minutes or 0.0 + # Set to whichever is higher: configured break or existing (penalty-inflated) value + new_val = max(break_min, current) + if new_val != current: + attendance.sudo().write({'x_fclk_break_minutes': new_val}) + +``` + Delete its clock-out call (in the CLOCK OUT branch): +```python + # Apply break deduction + self._apply_break_deduction(attendance, employee) + +``` + Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it): +```python + # Deduct penalty minutes from attendance (adds to break deduction) + current_break = attendance.x_fclk_break_minutes or 0.0 + attendance.sudo().write({ + 'x_fclk_break_minutes': current_break + deduction, + }) + +``` + +- [ ] **Step 7: Delete the kiosk call sites** + + In `fusion_clock/controllers/clock_kiosk.py`, delete the line: +```python + api._apply_break_deduction(attendance, employee) +``` + In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line: +```python + api._apply_break_deduction(attendance, employee) +``` + +- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly) + +```bash +docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py +``` + Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import). + +- [ ] **Step 9: Run to verify all Task 3 tests pass** + + Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS. + +- [ ] **Step 10: Commit** + +```bash +git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py +git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +git -C "K:/Github/Odoo-Modules" push +``` + +--- + +## Task 4: Retire `break_threshold_hours`; clean settings & migrate + +**Files:** +- Modify: `fusion_clock/models/res_config_settings.py` +- Modify: `fusion_clock/views/res_config_settings_views.xml` +- Modify: `fusion_clock/data/ir_config_parameter_data.xml` +- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` +- Modify: `fusion_clock/tests/test_settings.py` + +- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`: + +```python + self.assertNotIn('fclk_break_threshold_hours', fields) +``` + +- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete: + +```python + fclk_break_threshold_hours = fields.Float( + string='Break Threshold (hours)', + config_parameter='fusion_clock.break_threshold_hours', + default=4.0, + help="Only deduct break if shift is longer than this many hours.", + ) +``` + +- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block: + + OLD: +```xml + + +
+
+
+
+
+
+
+``` + NEW: +```xml + + +
+
+
+
+ Used as the default break when building shifts/schedules + (planned hours). Actual deductions follow the province Break Rules. +
+
+
+``` + +- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete: + +```xml + + fusion_clock.break_threshold_hours + 4.0 + +``` + +- [ ] **Step 5: Create the migration** — `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, SUPERUSER_ID + + +def migrate(cr, version): + """Retire the single-threshold break param (superseded by per-rule + break1_after_hours), and force-recompute the now-computed break field so + existing closed attendances reflect the province rule + their penalties.""" + cr.execute( + "DELETE FROM ir_config_parameter WHERE key = %s", + ('fusion_clock.break_threshold_hours',), + ) + env = api.Environment(cr, SUPERUSER_ID, {}) + Attendance = env['hr.attendance'] + field = Attendance._fields['x_fclk_break_minutes'] + closed = Attendance.search([('check_out', '!=', False)]) + if closed: + env.add_to_compute(field, closed) + closed.flush_recordset(['x_fclk_break_minutes']) +``` + +- [ ] **Step 6: Sync, upgrade, run tests** + + Sync, then run the module tests. Expected: module upgrades cleanly (migration runs — note nexa/entech run `log_level=warn`, but modsdev shows INFO), `test_dead_settings_removed` PASS, full `fusion_clock` suite green. + +- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity) + +```bash +docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY' +ICP = env['ir.config_parameter'].sudo() +print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT')) +print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name')) +PY +``` + Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`. + +- [ ] **Step 8: Commit** + +```bash +git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py +git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +git -C "K:/Github/Odoo-Modules" push +``` + +--- + +## Task 5: Full verification, docs, manual smoke + +**Files:** +- Modify: `fusion_clock/CLAUDE.md` + +- [ ] **Step 1: Full test run (whole module)** + + Sync, then: +```bash +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 -120 +``` + Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing. + +- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082 + + - Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked. + - Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action. + - Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h. + - Create a 4h attendance → **Break = 0**. + - Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules. + +- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`: + + - §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).` + - §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default). + - §11 Settings Keys: remove `fusion_clock.break_threshold_hours`. + - §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)." + - Bump the version line in §1 to `19.0.4.1.0`. + +- [ ] **Step 4: Commit the docs** + +```bash +git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md +git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " +git -C "K:/Github/Odoo-Modules" push +``` + +- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off. + +--- + +## Self-Review (performed against the spec) + +**1. Spec coverage** +- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps. + +**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓ + +**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓