Files
Odoo-Modules/docs/superpowers/plans/2026-05-31-fusion-clock-statutory-break.md
2026-05-31 23:50:08 -04:00

41 KiB
Raw Blame History

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:

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:
    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):
    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):
    docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.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.pyx_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:

# -*- 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):

from . import test_break_rules
  • Step 2: Create the modelfusion_clock/models/clock_break_rule.py:
# -*- 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:
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 version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
    <record id="break_rule_ontario" model="fusion.clock.break.rule">
        <field name="name">Ontario</field>
        <field name="country_id" ref="base.ca"/>
        <field name="state_id" ref="base.state_ca_on"/>
        <field name="is_default" eval="True"/>
        <field name="break1_after_hours">5.0</field>
        <field name="break1_minutes">30.0</field>
        <field name="break2_after_hours">10.0</field>
        <field name="break2_minutes">30.0</field>
    </record>
</odoo>
  • Step 6: Views + action — create fusion_clock/views/clock_break_rule_views.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
        <field name="name">fusion.clock.break.rule.list</field>
        <field name="model">fusion.clock.break.rule</field>
        <field name="arch" type="xml">
            <list>
                <field name="sequence" widget="handle"/>
                <field name="name"/>
                <field name="state_id"/>
                <field name="country_id" optional="hide"/>
                <field name="break1_after_hours" widget="float_time"/>
                <field name="break1_minutes"/>
                <field name="break2_after_hours" widget="float_time"/>
                <field name="break2_minutes"/>
                <field name="is_default"/>
                <field name="active" widget="boolean_toggle"/>
            </list>
        </field>
    </record>

    <record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
        <field name="name">fusion.clock.break.rule.form</field>
        <field name="model">fusion.clock.break.rule</field>
        <field name="arch" type="xml">
            <form>
                <sheet>
                    <widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
                            invisible="active"/>
                    <div class="oe_title">
                        <h1><field name="name" placeholder="e.g. Ontario"/></h1>
                    </div>
                    <group>
                        <group string="Jurisdiction">
                            <field name="country_id"/>
                            <field name="state_id"
                                   domain="[('country_id', '=', country_id)]"/>
                            <field name="is_default"/>
                            <field name="active"/>
                        </group>
                        <group string="Unpaid Break Tiers">
                            <label for="break1_after_hours" string="First break after"/>
                            <div class="o_row">
                                <field name="break1_after_hours" widget="float_time"/>
                                <span>h  →</span>
                                <field name="break1_minutes"/>
                                <span>min</span>
                            </div>
                            <label for="break2_after_hours" string="Second break after"/>
                            <div class="o_row">
                                <field name="break2_after_hours" widget="float_time"/>
                                <span>h  →</span>
                                <field name="break2_minutes"/>
                                <span>min</span>
                            </div>
                        </group>
                    </group>
                    <p class="text-muted">
                        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.
                    </p>
                </sheet>
            </form>
        </field>
    </record>

    <record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
        <field name="name">Break Rules</field>
        <field name="res_model">fusion.clock.break.rule</field>
        <field name="view_mode">list,form</field>
        <field name="context">{'active_test': False}</field>
        <field name="help" type="html">
            <p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
            <p>Define unpaid meal-break thresholds per province/country. Employees inherit
               the rule matching their company's province, or the default rule.</p>
        </field>
    </record>
</odoo>
  • 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:
    <menuitem id="menu_fusion_clock_break_rules"
              name="Break Rules"
              parent="menu_fusion_clock_config"
              action="action_fusion_clock_break_rule"
              sequence="25"
              groups="group_fusion_clock_manager"/>
  • 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',:

        'data/clock_break_rule_data.xml',

Add the view file after 'views/clock_schedule_views.xml',:

        '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:

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 23 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
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) <noreply@anthropic.com>"
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:

    # ---- 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:

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):
    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

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) <noreply@anthropic.com>"
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:

    # ---- 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:

    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:

    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):
    @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):

        threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))

Remove the two now-unused locals in the per-attendance loop:

            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):

                    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:

    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):

                # 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):

            # 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:

                api._apply_break_deduction(attendance, employee)

In fusion_clock/controllers/clock_nfc_kiosk.py, delete the line:

            api._apply_break_deduction(attendance, employee)
  • Step 8: Pyflakes the touched controllers/models (catches a missed pytz/var reference instantly)
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

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) <noreply@anthropic.com>"
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:

        self.assertNotIn('fclk_break_threshold_hours', fields)
  • Step 2: Remove the settings field — in fusion_clock/models/res_config_settings.py, delete:
    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:

                        <setting id="fclk_auto_break" string="Auto-Deduct Break"
                                 help="Automatically deduct unpaid break from worked hours on clock-out.">
                            <field name="fclk_auto_deduct_break"/>
                            <div class="content-group" invisible="not fclk_auto_deduct_break">
                                <div class="row mt16">
                                    <label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_default_break_minutes"/>
                                </div>
                                <div class="row mt8">
                                    <label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_break_threshold_hours" widget="float_time"/>
                                </div>
                            </div>
                        </setting>

NEW:

                        <setting id="fclk_auto_break" string="Auto-Deduct Break"
                                 help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
                            <field name="fclk_auto_deduct_break"/>
                            <div class="content-group" invisible="not fclk_auto_deduct_break">
                                <div class="row mt16">
                                    <label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_default_break_minutes"/>
                                </div>
                                <div class="text-muted small mt4">
                                    Used as the default break when building shifts/schedules
                                    (planned hours). Actual deductions follow the province Break Rules.
                                </div>
                            </div>
                        </setting>
  • Step 4: Remove the seed param — in fusion_clock/data/ir_config_parameter_data.xml, delete:
    <record id="config_break_threshold_hours" model="ir.config_parameter">
        <field name="key">fusion_clock.break_threshold_hours</field>
        <field name="value">4.0</field>
    </record>
  • 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:

    'version': '19.0.4.1.0',

Then create fusion_clock/migrations/19.0.4.1.0/post-migrate.py:

# -*- 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)

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
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) <noreply@anthropic.com>"
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:

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

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) <noreply@anthropic.com>"
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 13. §8 rollout → preamble + Task 5. ✓ No gaps.

2. Placeholder scan — every step has full code/commands; no TBD/TODO/"similar to". ✓

3. Type/name consistencybreak_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). ✓