# 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`: **Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the `19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration when the installed version is *lower* than the manifest version). 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', ``` (Data and view files reload on every `-u` regardless of the version number, so the new model/menu install without a bump. No assets change in this plan, so the bump's only purpose is the migration trigger — deferred to Task 4.) - [ ] **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: Bump the version + create the migration** First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest `19.0.4.1.0`). In `fusion_clock/__manifest__.py`: ```python 'version': '19.0.4.1.0', ``` Then create `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 and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `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 fusion_clock/__manifest__.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). ✓