Files
Odoo-Modules/docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
2026-05-14 07:07:45 -04:00

104 KiB
Raw Blame History

NFC Clock Kiosk 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: Add a Web NFC tap-to-clock kiosk to fusion_clock so employees can clock in/out with an NFC card on a wall-mounted Android tablet, with silent photo verification on every tap.

Architecture: New /fusion_clock/kiosk/nfc route + dedicated QWeb template + JS state machine using the Web NFC NDEFReader API and getUserMedia for the camera. The Python controller calls into the existing FusionClockAPI helpers (_attendance_action_change, _log_activity, _check_and_create_penalty, _apply_break_deduction) so all geofencing/penalty/activity logic is shared with the existing PIN kiosk. Single station per company; location is resolved from a new field on res.company.

Tech stack: Odoo 19 (Python 3.12, OWL/QWeb), Web NFC API (NDEFReader, Chrome ≥89 on Android), getUserMedia Camera API, SCSS (per-bundle dark/light branching), Odoo HttpCase tests.

Reference spec: docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md

Dev environment:

  • Odoo container: odoo-modsdev-app
  • Database: modsdev
  • Local URL: http://localhost:8069
  • Module reload: docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init
  • Run tests: docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -i fusion_clock

File Structure

New files:

  • fusion_clock/models/res_company.pyres.company extension with NFC kiosk location field
  • fusion_clock/controllers/clock_nfc_kiosk.py — controller with three endpoints
  • fusion_clock/views/kiosk_nfc_templates.xml — QWeb template for the kiosk page
  • fusion_clock/static/src/scss/nfc_kiosk.scss — always-dark, high-contrast styling
  • fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js — Web NFC + camera + state machine
  • fusion_clock/tests/__init__.py — test package init (first time)
  • fusion_clock/tests/test_clock_nfc_kiosk.py — unit tests for the controller
  • fusion_clock/tests/test_nfc_models.py — unit tests for the model fields

Modified files:

  • fusion_clock/__manifest__.py — bump version, register new files in data and assets
  • fusion_clock/__init__.py — import the new tests package
  • fusion_clock/models/__init__.py — import res_company
  • fusion_clock/models/hr_employee.py — add x_fclk_nfc_card_uid field
  • fusion_clock/models/hr_attendance.py — add photo fields, extend x_fclk_clock_source selection
  • fusion_clock/models/res_config_settings.py — add the four new ir.config_parameter shortcuts and the per-company location field
  • fusion_clock/views/res_config_settings_views.xml — add NFC Clock Kiosk block
  • fusion_clock/data/ir_config_parameter_data.xml — add four new defaults
  • fusion_clock/controllers/__init__.py — import clock_nfc_kiosk

Each file has one responsibility:

  • Model files extend their respective Odoo models with NFC-specific fields only
  • The controller is single-purpose (NFC kiosk endpoints)
  • The template, SCSS, and JS form a coherent frontend triple — all named nfc_kiosk for grep discoverability
  • Tests are split: test_nfc_models.py (data layer) vs test_clock_nfc_kiosk.py (HTTP/controller)

Task 1: Add x_fclk_nfc_card_uid field to hr.employee

Files:

  • Create: fusion_clock/tests/__init__.py

  • Create: fusion_clock/tests/test_nfc_models.py

  • Modify: fusion_clock/models/hr_employee.py (add field after the existing x_fclk_kiosk_pin field, around line 48)

  • Modify: fusion_clock/__init__.py (add from . import tests if not present — actually for Odoo, tests are auto-discovered via tests/__init__.py; no module-level import needed)

  • Step 1: Create the tests package init

Create fusion_clock/tests/__init__.py:

# -*- coding: utf-8 -*-

from . import test_nfc_models
  • Step 2: Write the failing test for the card UID field

Create fusion_clock/tests/test_nfc_models.py:

# -*- coding: utf-8 -*-

from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import ValidationError
from psycopg2 import IntegrityError
from odoo.tools.misc import mute_logger


@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcModels(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Employee = cls.env['hr.employee']
        cls.alice = cls.Employee.create({'name': 'Alice NFC', 'x_fclk_enable_clock': True})
        cls.bob = cls.Employee.create({'name': 'Bob NFC', 'x_fclk_enable_clock': True})

    def test_card_uid_is_writable(self):
        self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
        self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')

    def test_card_uid_is_unique_when_set(self):
        self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
        with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
            with self.env.cr.savepoint():
                self.bob.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
                self.bob.flush_recordset(['x_fclk_nfc_card_uid'])

    def test_card_uid_can_be_null_for_multiple_employees(self):
        self.alice.x_fclk_nfc_card_uid = False
        self.bob.x_fclk_nfc_card_uid = False
        self.assertFalse(self.alice.x_fclk_nfc_card_uid)
        self.assertFalse(self.bob.x_fclk_nfc_card_uid)
  • Step 3: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -i fusion_clock 2>&1 | tail -40

Expected: errors about x_fclk_nfc_card_uid not being a valid field on hr.employee.

  • Step 4: Add the field to hr.employee

In fusion_clock/models/hr_employee.py, insert after the existing x_fclk_kiosk_pin field (around line 48):

    # NFC card (kiosk identification)
    x_fclk_nfc_card_uid = fields.Char(
        string='NFC Card UID',
        index=True,
        copy=False,
        groups="fusion_clock.group_fusion_clock_manager",
        help="Hex UID of the NFC card assigned to this employee. "
             "Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
             "Same card the employee uses for door access.",
    )

    _sql_constraints = [
        (
            'fclk_nfc_card_uid_unique',
            'UNIQUE(x_fclk_nfc_card_uid)',
            'This NFC card is already assigned to another employee.',
        ),
    ]

Note: if _sql_constraints already exists on the class, merge the tuple into the existing list rather than redefining it. Search the file for _sql_constraints before adding.

  • Step 5: Run the test to confirm it passes
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: 0 failed, 0 error(s) for the three test methods.

  • Step 6: Commit
git add fusion_clock/models/hr_employee.py fusion_clock/tests/__init__.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): add x_fclk_nfc_card_uid to hr.employee"

Task 2: Add photo fields, 'nfc_kiosk' source, and activity-log selections

Files:

  • Modify: fusion_clock/models/hr_attendance.py (extend x_fclk_clock_source selection and add two photo fields after the existing x_fclk_out_distance at ~line 149)

  • Modify: fusion_clock/models/clock_activity_log.py (extend log_type and source selections to support NFC kiosk events)

  • Modify: fusion_clock/tests/test_nfc_models.py (add a new test class)

  • Step 1: Write the failing test

Append to fusion_clock/tests/test_nfc_models.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcAttendanceFields(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.employee = cls.env['hr.employee'].create({
            'name': 'NFC Test Employee',
            'x_fclk_enable_clock': True,
        })

    def test_clock_source_includes_nfc_kiosk(self):
        attendance = self.env['hr.attendance'].create({
            'employee_id': self.employee.id,
            'check_in': '2026-05-13 08:00:00',
            'x_fclk_clock_source': 'nfc_kiosk',
        })
        self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')

    def test_photo_fields_accept_binary(self):
        # 1x1 transparent PNG as base64
        png_b64 = (
            b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
            b'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
        )
        attendance = self.env['hr.attendance'].create({
            'employee_id': self.employee.id,
            'check_in': '2026-05-13 08:00:00',
            'x_fclk_check_in_photo': png_b64,
        })
        self.assertTrue(attendance.x_fclk_check_in_photo)

    def test_activity_log_accepts_new_selections(self):
        log = self.env['fusion.clock.activity.log'].create({
            'employee_id': self.employee.id,
            'log_type': 'card_enrollment',
            'source': 'nfc_kiosk',
            'description': 'Test enrollment log',
        })
        self.assertEqual(log.log_type, 'card_enrollment')
        self.assertEqual(log.source, 'nfc_kiosk')

        log2 = self.env['fusion.clock.activity.log'].create({
            'employee_id': self.employee.id,
            'log_type': 'unknown_card_tap',
            'source': 'nfc_kiosk',
            'description': 'Test unknown card log',
        })
        self.assertEqual(log2.log_type, 'unknown_card_tap')
  • Step 2: Update tests/init.py imports if needed

Tests are in the same file, no additional import needed.

  • Step 3: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: nfc_kiosk not a valid Selection value; x_fclk_check_in_photo not a valid field.

  • Step 4: Extend the clock_source selection and add photo fields

In fusion_clock/models/hr_attendance.py, replace the existing x_fclk_clock_source field (lines 126139) with:

    x_fclk_clock_source = fields.Selection(
        [
            ('portal', 'Portal'),
            ('portal_fab', 'Portal FAB'),
            ('systray', 'Systray'),
            ('backend_fab', 'Backend FAB'),
            ('kiosk', 'Kiosk'),
            ('nfc_kiosk', 'NFC Kiosk'),
            ('manual', 'Manual'),
            ('auto', 'Auto Clock-Out'),
        ],
        string='Clock Source',
        tracking=True,
        help="How this attendance was recorded.",
    )

Then, after the existing x_fclk_out_distance field (~line 149), add:

    x_fclk_check_in_photo = fields.Binary(
        string='Check-In Photo',
        attachment=True,
        help="Front-camera photo captured at NFC kiosk clock-in.",
    )
    x_fclk_check_out_photo = fields.Binary(
        string='Check-Out Photo',
        attachment=True,
        help="Front-camera photo captured at NFC kiosk clock-out.",
    )
  • Step 5: Extend the activity log selections

In fusion_clock/models/clock_activity_log.py, locate the log_type Selection field (~line 21) and ADD these two entries to the list (anywhere is fine; add at the end of the existing tuples):

            ('card_enrollment', 'Card Enrollment'),
            ('unknown_card_tap', 'Unknown Card Tap'),

Then locate the source Selection field (~line 67) and ADD this entry:

            ('nfc_kiosk', 'NFC Kiosk'),

The two updated fields should look like:

    log_type = fields.Selection(
        [
            ('clock_in', 'Clock In'),
            ('clock_out', 'Clock Out'),
            ('late_clock_in', 'Late Clock-In'),
            ('early_clock_out', 'Early Clock-Out'),
            ('outside_geofence', 'Outside Geofence'),
            ('auto_clock_out', 'Auto Clock-Out'),
            ('missed_clock_out', 'Missed Clock-Out'),
            ('absent', 'Absent'),
            ('leave_request', 'Leave Request'),
            ('reason_provided', 'Reason Provided'),
            ('overtime', 'Overtime'),
            ('correction_request', 'Correction Request'),
            ('ip_fallback', 'IP Fallback Used'),
            ('streak_milestone', 'Streak Milestone'),
            ('card_enrollment', 'Card Enrollment'),
            ('unknown_card_tap', 'Unknown Card Tap'),
        ],
        ...
    )

    source = fields.Selection(
        [
            ('portal', 'Portal'),
            ('portal_fab', 'Portal FAB'),
            ('systray', 'Systray'),
            ('backend_fab', 'Backend FAB'),
            ('kiosk', 'Kiosk'),
            ('nfc_kiosk', 'NFC Kiosk'),
            ('system', 'System (Cron)'),
        ],
        string='Source',
    )

(Preserve any existing fields between log_type and source — only the inner tuple lists change.)

  • Step 6: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: 6 tests pass (3 from Task 1 + 3 new).

  • Step 7: Commit
git add fusion_clock/models/hr_attendance.py fusion_clock/models/clock_activity_log.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): NFC kiosk attendance fields + activity-log selections"

Task 3: Add x_fclk_nfc_kiosk_location_id to res.company

Files:

  • Create: fusion_clock/models/res_company.py

  • Modify: fusion_clock/models/__init__.py (add from . import res_company)

  • Modify: fusion_clock/tests/test_nfc_models.py (add a test class)

  • Step 1: Write the failing test

Append to fusion_clock/tests/test_nfc_models.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskCompanyField(TransactionCase):

    def test_company_has_nfc_kiosk_location(self):
        company = self.env.company
        location = self.env['fusion.clock.location'].create({
            'name': 'Plant 1',
            'latitude': 43.65,
            'longitude': -79.38,
            'radius': 100,
        })
        company.x_fclk_nfc_kiosk_location_id = location.id
        self.assertEqual(company.x_fclk_nfc_kiosk_location_id, location)

    def test_company_field_defaults_to_false(self):
        new_company = self.env['res.company'].create({'name': 'Test Co NFC'})
        self.assertFalse(new_company.x_fclk_nfc_kiosk_location_id)
  • Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: x_fclk_nfc_kiosk_location_id not a valid field on res.company.

Heads-up: the fusion.clock.location model may have additional required fields (e.g. address, IP whitelist) — if create({...}) errors with "Required field missing", inspect fusion_clock/models/clock_location.py and add the missing keys to the test fixture.

  • Step 3: Create the res.company extension

Create fusion_clock/models/res_company.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)

from odoo import models, fields


class ResCompany(models.Model):
    _inherit = 'res.company'

    x_fclk_nfc_kiosk_location_id = fields.Many2one(
        'fusion.clock.location',
        string='NFC Kiosk Location',
        help="Designates which fusion.clock.location is bound to the NFC kiosk "
             "for this company. Required when NFC kiosk is enabled.",
    )
  • Step 4: Register the new model file

In fusion_clock/models/__init__.py, add:

from . import res_company

(append at the bottom, after from . import clock_correction)

  • Step 5: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: 8 tests pass (6 from previous tasks + 2 new).

  • Step 6: Commit
git add fusion_clock/models/res_company.py fusion_clock/models/__init__.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): add NFC kiosk location to res.company"

Task 4: Add ir.config_parameter defaults

Files:

  • Modify: fusion_clock/data/ir_config_parameter_data.xml (append to the bottom before </odoo>)

  • Step 1: Add the four NFC kiosk parameters

Open fusion_clock/data/ir_config_parameter_data.xml. Find the closing </odoo> tag at the end of the file and insert the following block immediately before it:

    <!-- NFC Clock Kiosk -->
    <record id="config_enable_nfc_kiosk" model="ir.config_parameter">
        <field name="key">fusion_clock.enable_nfc_kiosk</field>
        <field name="value">False</field>
    </record>
    <record id="config_nfc_photo_required" model="ir.config_parameter">
        <field name="key">fusion_clock.nfc_photo_required</field>
        <field name="value">True</field>
    </record>
    <record id="config_nfc_enroll_password" model="ir.config_parameter">
        <field name="key">fusion_clock.nfc_enroll_password</field>
        <field name="value"></field>
    </record>
    <record id="config_nfc_kiosk_debug" model="ir.config_parameter">
        <field name="key">fusion_clock.nfc_kiosk_debug</field>
        <field name="value">False</field>
    </record>
  • Step 2: Reload the module and confirm parameters are present
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -10

Expected: clean reload, no errors.

Then verify in odoo-shell:

docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
ICP = env['ir.config_parameter'].sudo()
for k in ['fusion_clock.enable_nfc_kiosk', 'fusion_clock.nfc_photo_required', 'fusion_clock.nfc_enroll_password', 'fusion_clock.nfc_kiosk_debug']:
    print(k, '=', repr(ICP.get_param(k)))
EOF

Expected output:

fusion_clock.enable_nfc_kiosk = 'False'
fusion_clock.nfc_photo_required = 'True'
fusion_clock.nfc_enroll_password = ''
fusion_clock.nfc_kiosk_debug = 'False'
  • Step 3: Commit
git add fusion_clock/data/ir_config_parameter_data.xml
git commit -m "feat(fusion_clock): add NFC kiosk ir.config_parameter defaults"

Task 5: Extend res.config.settings + view

Files:

  • Modify: fusion_clock/models/res_config_settings.py (add four config_parameter shortcuts and a per-company Many2one)

  • Modify: fusion_clock/views/res_config_settings_views.xml (add an "NFC Clock Kiosk" block)

  • Step 1: Add settings fields

Open fusion_clock/models/res_config_settings.py. At the bottom of the ResConfigSettings class (before the closing of the class), add:

    # ── NFC Clock Kiosk ────────────────────────────────────────────────
    fclk_enable_nfc_kiosk = fields.Boolean(
        string='Enable NFC Clock Kiosk',
        config_parameter='fusion_clock.enable_nfc_kiosk',
        default=False,
        help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
    )
    fclk_nfc_photo_required = fields.Boolean(
        string='Require Photo on Tap',
        config_parameter='fusion_clock.nfc_photo_required',
        default=True,
        help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
             "Recommended for buddy-punch deterrence.",
    )
    fclk_nfc_enroll_password = fields.Char(
        string='Enroll Mode Password',
        config_parameter='fusion_clock.nfc_enroll_password',
        help="Short password the manager types on the kiosk to enter Enroll Mode. "
             "Leave empty to fall back to manager-group membership only.",
    )
    fclk_nfc_kiosk_debug = fields.Boolean(
        string='Enable Mock-Tap Debug',
        config_parameter='fusion_clock.nfc_kiosk_debug',
        default=False,
        help="Enables a Ctrl+Shift+T keyboard shortcut on the kiosk page for "
             "simulating a tap with a configurable UID. Off in production.",
    )
    fclk_nfc_kiosk_location_id = fields.Many2one(
        related='company_id.x_fclk_nfc_kiosk_location_id',
        readonly=False,
        string='NFC Kiosk Location',
        help="Which clock location is bound to the NFC kiosk for this company. "
             "Required when the kiosk is enabled.",
    )
  • Step 2: Add the settings block to the view

Open fusion_clock/views/res_config_settings_views.xml. Find the last </block> inside the <app> element (look for the closing of an existing block near the end of the file), and add the following block immediately after it (still inside the <app> element):

                    <!-- ============================================================ -->
                    <!-- NFC Clock Kiosk -->
                    <!-- ============================================================ -->
                    <block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
                        <setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
                                 help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
                            <field name="fclk_enable_nfc_kiosk"/>
                            <div class="content-group" invisible="not fclk_enable_nfc_kiosk">
                                <div class="row mt16">
                                    <label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_nfc_kiosk_location_id"/>
                                </div>
                                <div class="row mt8">
                                    <label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_nfc_photo_required"/>
                                </div>
                                <div class="row mt8">
                                    <label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_nfc_enroll_password" password="True"/>
                                </div>
                                <div class="row mt8">
                                    <label for="fclk_nfc_kiosk_debug" string="Mock-Tap Debug" class="col-lg-5 o_light_label"/>
                                    <field name="fclk_nfc_kiosk_debug"/>
                                </div>
                            </div>
                        </setting>
                    </block>
  • Step 3: Reload and verify in browser
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5

Then in browser, navigate to http://localhost:8069/odoo/settings → Fusion Clock app → scroll to "NFC Clock Kiosk" block. Confirm:

  • The "Enable NFC Kiosk" toggle is visible

  • Toggling it on reveals the Location, Require Photo, Enroll Password, Mock-Tap Debug fields

  • The Location picker shows existing fusion.clock.location records

  • Step 4: Commit

git add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml
git commit -m "feat(fusion_clock): add NFC Clock Kiosk settings block"

Task 6: Create controller scaffold + page render route

Files:

  • Create: fusion_clock/controllers/clock_nfc_kiosk.py

  • Modify: fusion_clock/controllers/__init__.py (add from . import clock_nfc_kiosk)

  • Create: fusion_clock/tests/test_clock_nfc_kiosk.py

  • Modify: fusion_clock/tests/__init__.py (add from . import test_clock_nfc_kiosk)

  • Step 1: Add the test scaffolding

Create fusion_clock/tests/test_clock_nfc_kiosk.py:

# -*- coding: utf-8 -*-

from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskController(HttpCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.location = cls.env['fusion.clock.location'].create({
            'name': 'Test Plant',
            'latitude': 43.65,
            'longitude': -79.38,
            'radius': 100,
        })
        cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
        # Create a kiosk service user in the manager group
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'NFC Kiosk User',
            'login': 'nfc-kiosk-test',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })

    def test_kiosk_page_redirects_when_disabled(self):
        self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
        self.authenticate('nfc-kiosk-test', 'kioskpass123')
        response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False)
        self.assertIn(response.status_code, (301, 302, 303))

    def test_kiosk_page_renders_when_enabled(self):
        self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        self.authenticate('nfc-kiosk-test', 'kioskpass123')
        response = self.url_open('/fusion_clock/kiosk/nfc')
        self.assertEqual(response.status_code, 200)
        self.assertIn('NFC Clock Kiosk', response.text)
  • Step 2: Register the test in tests/init.py

Edit fusion_clock/tests/__init__.py:

# -*- coding: utf-8 -*-

from . import test_nfc_models
from . import test_clock_nfc_kiosk
  • Step 3: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: 404 errors on /fusion_clock/kiosk/nfc (route not yet defined).

  • Step 4: Create the controller scaffold

Create fusion_clock/controllers/clock_nfc_kiosk.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)

import logging
from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


class FusionClockNfcKiosk(http.Controller):
    """NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""

    @http.route('/fusion_clock/kiosk/nfc', type='http', auth='user', website=True)
    def nfc_kiosk_page(self, **kw):
        """Render the NFC kiosk page for a wall-mounted tablet."""
        user = request.env.user
        if not user.has_group('fusion_clock.group_fusion_clock_manager'):
            return request.redirect('/my')

        ICP = request.env['ir.config_parameter'].sudo()
        if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
            return request.redirect('/my')

        company = request.env.company
        location = company.x_fclk_nfc_kiosk_location_id
        values = {
            'page_name': 'nfc_kiosk',
            'company_name': company.name,
            'location_name': location.name if location else 'No location configured',
            'location_configured': bool(location),
            'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
            'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
        }
        return request.render('fusion_clock.nfc_kiosk_page', values)
  • Step 5: Register the controller in controllers/init.py

Edit fusion_clock/controllers/__init__.py:

# -*- coding: utf-8 -*-

from . import portal_clock
from . import clock_api
from . import clock_kiosk
from . import clock_nfc_kiosk
  • Step 6: Add a placeholder template so the render call has something to find

Create fusion_clock/views/kiosk_nfc_templates.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>

    <template id="nfc_kiosk_page" name="NFC Clock Kiosk">
        <t t-call="web.frontend_layout">
            <t t-set="no_header" t-value="True"/>
            <t t-set="no_footer" t-value="True"/>
            <div id="nfc_kiosk_root" class="nfc-kiosk">
                <h1>NFC Clock Kiosk</h1>
                <div t-if="not location_configured" class="alert alert-warning">
                    No NFC kiosk location configured for <t t-esc="company_name"/>. Ask your administrator to configure one in Fusion Clock settings.
                </div>
                <div t-else="">
                    <p>Clock at: <span t-esc="location_name"/></p>
                </div>
            </div>
        </t>
    </template>

</odoo>
  • Step 7: Register the template + controller in the manifest

Edit fusion_clock/__manifest__.py. In the 'data' list, add 'views/kiosk_nfc_templates.xml' immediately after the existing 'views/kiosk_templates.xml' entry. The new line should read:

        'views/kiosk_nfc_templates.xml',

(The controllers don't need to be registered in the manifest — Odoo discovers them via the Python imports.)

  • Step 8: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: both new tests pass.

  • Step 9: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/controllers/__init__.py fusion_clock/views/kiosk_nfc_templates.xml fusion_clock/__manifest__.py fusion_clock/tests/test_clock_nfc_kiosk.py fusion_clock/tests/__init__.py
git commit -m "feat(fusion_clock): NFC kiosk page render route"

Task 7: UID normalization helper

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (add a _normalize_uid static helper on the controller class)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add a TransactionCase test class for the helper)

  • Step 1: Write the failing test

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

from odoo.tests.common import TransactionCase
from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk


@tagged('-at_install', 'post_install', 'fusion_clock')
class TestUidNormalization(TransactionCase):

    def test_lowercase_input_uppercased(self):
        self.assertEqual(
            FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'),
            '04:A2:B5:62:C1:80',
        )

    def test_no_separator_input_gets_colons(self):
        self.assertEqual(
            FusionClockNfcKiosk._normalize_uid('04A2B562C180'),
            '04:A2:B5:62:C1:80',
        )

    def test_dash_separator_replaced(self):
        self.assertEqual(
            FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'),
            '04:A2:B5:62:C1:80',
        )

    def test_whitespace_stripped(self):
        self.assertEqual(
            FusionClockNfcKiosk._normalize_uid('  04:A2:B5:62:C1:80  '),
            '04:A2:B5:62:C1:80',
        )

    def test_empty_input_returns_none(self):
        self.assertIsNone(FusionClockNfcKiosk._normalize_uid(''))
        self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None))

    def test_invalid_chars_returns_none(self):
        self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid'))
        self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80'))

    def test_odd_length_returns_none(self):
        self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18'))
  • Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: AttributeError: type object 'FusionClockNfcKiosk' has no attribute '_normalize_uid'.

  • Step 3: Add the helper to the controller

In fusion_clock/controllers/clock_nfc_kiosk.py, add the following imports and method to the class. Place the method definition before nfc_kiosk_page:

import re

_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')


class FusionClockNfcKiosk(http.Controller):
    """NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""

    @staticmethod
    def _normalize_uid(uid):
        """Normalize an NFC card UID to canonical hex (uppercase, colon-separated).

        Returns None if the input is empty or not valid hex.
        """
        if not uid:
            return None
        cleaned = uid.strip().upper().replace('-', '').replace(':', '').replace(' ', '')
        if not cleaned or not _UID_HEX_PATTERN.match(cleaned):
            return None
        if len(cleaned) % 2 != 0:
            return None
        # Re-insert colons every 2 chars
        return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2))

(Make sure import re is at the top of the file with the other imports.)

  • Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: 7 normalization tests pass.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC card UID normalization helper"

Task 8: Enroll endpoint

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (add nfc_enroll method and helper for password check)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add TestEnrollEndpoint class)

  • Step 1: Write the failing tests

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

import json


@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEnrollEndpoint(HttpCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'Enroll Kiosk User',
            'login': 'nfc-kiosk-enroll',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })
        cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True})
        cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True})

    def _call(self, payload):
        self.authenticate('nfc-kiosk-enroll', 'kioskpass123')
        response = self.url_open(
            '/fusion_clock/kiosk/nfc/enroll',
            data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
            headers={'Content-Type': 'application/json'},
        )
        return response.json().get('result', {})

    def test_enroll_success(self):
        result = self._call({
            'employee_id': self.alice.id,
            'card_uid': '04:a2:b5:62:c1:80',
            'enroll_password': '1234',
        })
        self.assertTrue(result.get('success'))
        self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80')
        self.alice.invalidate_recordset()
        self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')

    def test_enroll_wrong_password(self):
        result = self._call({
            'employee_id': self.alice.id,
            'card_uid': '04:A2:B5:62:C1:81',
            'enroll_password': 'wrong',
        })
        self.assertEqual(result.get('error'), 'invalid_password')
        self.alice.invalidate_recordset()
        self.assertFalse(self.alice.x_fclk_nfc_card_uid)

    def test_enroll_card_already_assigned(self):
        self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82'
        result = self._call({
            'employee_id': self.bob.id,
            'card_uid': '04:A2:B5:62:C1:82',
            'enroll_password': '1234',
        })
        self.assertEqual(result.get('error'), 'card_already_assigned')
        self.assertEqual(result.get('existing_employee'), 'Alice E')
        self.bob.invalidate_recordset()
        self.assertFalse(self.bob.x_fclk_nfc_card_uid)

    def test_enroll_invalid_uid(self):
        result = self._call({
            'employee_id': self.alice.id,
            'card_uid': 'not-a-uid',
            'enroll_password': '1234',
        })
        self.assertEqual(result.get('error'), 'invalid_uid')
  • Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: 404 on /fusion_clock/kiosk/nfc/enroll.

  • Step 3: Add the enroll endpoint

Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the FusionClockNfcKiosk class):

    @staticmethod
    def _check_enroll_password(env, supplied):
        """Verify the enroll-mode password. Empty config = always-allow for managers."""
        configured = env['ir.config_parameter'].sudo().get_param('fusion_clock.nfc_enroll_password', '')
        if not configured:
            return True
        return (supplied or '') == configured

    @http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
    def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
        """Bind an NFC card UID to an employee. Manager-gated, password-gated."""
        user = request.env.user
        if not user.has_group('fusion_clock.group_fusion_clock_manager'):
            return {'error': 'access_denied'}

        if not self._check_enroll_password(request.env, enroll_password):
            return {'error': 'invalid_password'}

        normalized = self._normalize_uid(card_uid)
        if not normalized:
            return {'error': 'invalid_uid'}

        Employee = request.env['hr.employee'].sudo()
        target = Employee.browse(int(employee_id or 0))
        if not target.exists():
            return {'error': 'employee_not_found'}

        existing = Employee.search([
            ('x_fclk_nfc_card_uid', '=', normalized),
            ('id', '!=', target.id),
        ], limit=1)
        if existing:
            return {
                'error': 'card_already_assigned',
                'existing_employee': existing.name,
            }

        target.x_fclk_nfc_card_uid = normalized

        # Activity log (uses 'card_enrollment' + 'nfc_kiosk' selections added in Task 2)
        request.env['fusion.clock.activity.log'].sudo().create({
            'employee_id': target.id,
            'log_type': 'card_enrollment',
            'description': f"NFC card {normalized} enrolled by {user.name}",
            'source': 'nfc_kiosk',
        })

        return {
            'success': True,
            'employee_name': target.name,
            'card_uid': normalized,
        }
  • Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: 4 enroll tests pass.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC card enrollment endpoint"

Task 9: Tap endpoint — happy path

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (add nfc_tap method)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add TestTapEndpointHappyPath)

  • Step 1: Write the failing tests

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointHappyPath(HttpCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
        cls.location = cls.env['fusion.clock.location'].create({
            'name': 'Tap Plant',
            'latitude': 43.65,
            'longitude': -79.38,
            'radius': 100,
        })
        cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'Tap Kiosk User',
            'login': 'nfc-kiosk-tap',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })
        cls.alice = cls.env['hr.employee'].create({
            'name': 'Alice T',
            'x_fclk_enable_clock': True,
            'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90',
        })

    def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''):
        self.authenticate('nfc-kiosk-tap', 'kioskpass123')
        response = self.url_open(
            '/fusion_clock/kiosk/nfc/tap',
            data=json.dumps({
                'jsonrpc': '2.0',
                'method': 'call',
                'params': {'card_uid': card_uid, 'photo_b64': photo_b64},
            }),
            headers={'Content-Type': 'application/json'},
        )
        return response.json().get('result', {})

    def test_first_tap_clocks_in(self):
        result = self._tap()
        self.assertTrue(result.get('success'))
        self.assertEqual(result.get('action'), 'clock_in')
        self.assertEqual(result.get('employee_name'), 'Alice T')

        # Verify attendance record
        attendance = self.env['hr.attendance'].search([
            ('employee_id', '=', self.alice.id),
        ], order='check_in desc', limit=1)
        self.assertTrue(attendance)
        self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
        self.assertEqual(attendance.x_fclk_location_id, self.location)
        self.assertFalse(attendance.check_out)

    def test_second_tap_clocks_out(self):
        # First tap (clock in)
        self._tap()
        # Second tap (clock out) — bypass debounce by manipulating last-tap state
        # Wait long enough that the 5s debounce window has elapsed
        import time
        time.sleep(6)
        result = self._tap()
        self.assertTrue(result.get('success'))
        self.assertEqual(result.get('action'), 'clock_out')

        attendance = self.env['hr.attendance'].search([
            ('employee_id', '=', self.alice.id),
        ], order='check_in desc', limit=1)
        self.assertTrue(attendance.check_out)
  • Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: 404 on /fusion_clock/kiosk/nfc/tap.

  • Step 3: Add the tap endpoint (happy path only — error cases come in Task 10)

Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the FusionClockNfcKiosk class):

    @http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
    def nfc_tap(self, card_uid='', photo_b64='', **kw):
        """Toggle attendance state for the employee owning this card UID."""
        from odoo import fields
        user = request.env.user
        if not user.has_group('fusion_clock.group_fusion_clock_manager'):
            return {'error': 'access_denied'}

        ICP = request.env['ir.config_parameter'].sudo()
        if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
            return {'error': 'kiosk_disabled'}

        normalized = self._normalize_uid(card_uid)
        if not normalized:
            return {'error': 'invalid_uid'}

        company = request.env.company
        location = company.x_fclk_nfc_kiosk_location_id
        if not location:
            return {'error': 'no_location_configured'}

        Employee = request.env['hr.employee'].sudo()
        employee = Employee.search([('x_fclk_nfc_card_uid', '=', normalized)], limit=1)
        if not employee:
            # Log the unknown tap so a manager can investigate.
            # employee_id is required by the model — link to a sentinel "system" employee
            # if one exists, otherwise just write the description with a 0 employee_id
            # via raw SQL. Simplest: skip the log when no employee, fall back to logger.
            _logger.warning("[nfc-kiosk] Unknown NFC card tapped: %s", normalized)
            return {'error': 'card_unknown', 'message': 'Card not enrolled. See your manager.'}

        if not employee.x_fclk_enable_clock:
            return {'error': 'clock_disabled', 'message': 'Clock disabled for this account.'}

        # Reuse the existing kiosk's clock logic via FusionClockAPI
        from .clock_api import FusionClockAPI
        api = FusionClockAPI()

        is_checked_in = employee.attendance_state == 'checked_in'
        now = fields.Datetime.now()
        today = now.date()

        geo_info = {
            'latitude': 0,
            'longitude': 0,
            'browser': 'nfc_kiosk',
            'ip_address': request.httprequest.remote_addr or '',
        }

        attendance = employee.sudo()._attendance_action_change(geo_info)

        if not is_checked_in:
            # Just clocked IN
            attendance.sudo().write({
                'x_fclk_location_id': location.id,
                'x_fclk_in_distance': 0.0,
                'x_fclk_clock_source': 'nfc_kiosk',
            })
            api._log_activity(
                employee, 'clock_in',
                f"NFC kiosk clock-in at {location.name}",
                attendance=attendance, location=location,
                latitude=0, longitude=0, distance=0,
                source='nfc_kiosk',
            )
            scheduled_in, _ = api._get_scheduled_times(employee, today)
            api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)

            return {
                'success': True,
                'action': 'clock_in',
                'employee_name': employee.name,
                'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
                'message': f'{employee.name} clocked in at {location.name}',
                'net_hours_today': 0.0,
            }
        else:
            # Just clocked OUT
            attendance.sudo().write({
                'x_fclk_out_distance': 0.0,
            })
            api._apply_break_deduction(attendance, employee)
            _, scheduled_out = api._get_scheduled_times(employee, today)
            api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
            api._log_activity(
                employee, 'clock_out',
                f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
                attendance=attendance, location=location,
                latitude=0, longitude=0, distance=0,
                source='nfc_kiosk',
            )

            return {
                'success': True,
                'action': 'clock_out',
                'employee_name': employee.name,
                'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
                'message': f'{employee.name} clocked out',
                'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
            }

Verify the imports at the top of the file include from odoo import http, fields (the existing scaffold may only have http). Add fields if missing.

  • Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: both tap tests pass.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap endpoint (happy path)"

Task 10: Tap endpoint — error handling and debounce

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (add module-level debounce dict + check)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add TestTapEndpointErrors)

  • Step 1: Write the failing tests

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointErrors(HttpCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
        cls.location = cls.env['fusion.clock.location'].create({
            'name': 'Err Plant',
            'latitude': 43.65,
            'longitude': -79.38,
            'radius': 100,
        })
        cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'Err Kiosk User',
            'login': 'nfc-kiosk-err',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })
        cls.disabled_emp = cls.env['hr.employee'].create({
            'name': 'Disabled E',
            'x_fclk_enable_clock': False,
            'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD',
        })
        cls.active_emp = cls.env['hr.employee'].create({
            'name': 'Active E',
            'x_fclk_enable_clock': True,
            'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01',
        })

    def _tap(self, card_uid):
        self.authenticate('nfc-kiosk-err', 'kioskpass123')
        response = self.url_open(
            '/fusion_clock/kiosk/nfc/tap',
            data=json.dumps({
                'jsonrpc': '2.0', 'method': 'call',
                'params': {'card_uid': card_uid, 'photo_b64': ''},
            }),
            headers={'Content-Type': 'application/json'},
        )
        return response.json().get('result', {})

    def test_unknown_card(self):
        result = self._tap('04:00:00:00:00:00')
        self.assertEqual(result.get('error'), 'card_unknown')

    def test_disabled_employee(self):
        result = self._tap('04:A2:B5:62:DE:AD')
        self.assertEqual(result.get('error'), 'clock_disabled')

    def test_no_location_configured(self):
        self.env.company.x_fclk_nfc_kiosk_location_id = False
        result = self._tap('04:A2:B5:62:AC:01')
        self.assertEqual(result.get('error'), 'no_location_configured')

    def test_kiosk_disabled(self):
        self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
        result = self._tap('04:A2:B5:62:AC:01')
        self.assertEqual(result.get('error'), 'kiosk_disabled')

    def test_invalid_uid(self):
        result = self._tap('not-a-uid')
        self.assertEqual(result.get('error'), 'invalid_uid')

    def test_debounce_silent_second_tap(self):
        first = self._tap('04:A2:B5:62:AC:01')
        self.assertTrue(first.get('success'))
        # Immediately tap again — should be debounced
        second = self._tap('04:A2:B5:62:AC:01')
        self.assertEqual(second.get('error'), 'debounce')
  • Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: most error tests pass already (the controller already returns card_unknown, clock_disabled, etc.) but test_debounce_silent_second_tap fails because debounce isn't implemented yet.

  • Step 3: Add module-level debounce state and check

Edit fusion_clock/controllers/clock_nfc_kiosk.py. At the top of the file, after the imports, add:

import time
import threading

_DEBOUNCE_WINDOW_SECONDS = 5.0
_recent_taps = {}  # {card_uid: monotonic_ts}
_recent_taps_lock = threading.Lock()


def _is_debounced(uid):
    """Return True if this UID was tapped within the debounce window."""
    now = time.monotonic()
    with _recent_taps_lock:
        last = _recent_taps.get(uid, 0)
        if now - last < _DEBOUNCE_WINDOW_SECONDS:
            return True
        _recent_taps[uid] = now
        # Opportunistic GC: drop entries older than 60s
        stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
        for k in stale_keys:
            _recent_taps.pop(k, None)
        return False

Then in the nfc_tap method, immediately AFTER the normalized = self._normalize_uid(card_uid) block (before resolving the location), insert:

        if _is_debounced(normalized):
            return {'error': 'debounce'}
  • Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: all 6 error tests pass.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap endpoint debounce + error handling tests"

Task 11: Tap endpoint — photo handling

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (decode photo_b64, save to attendance, enforce nfc_photo_required)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add TestTapPhotoHandling)

  • Step 1: Write the failing tests

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapPhotoHandling(HttpCase):

    SAMPLE_PNG_DATAURL = (
        'data:image/png;base64,'
        'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
        'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
    )

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        cls.location = cls.env['fusion.clock.location'].create({
            'name': 'Photo Plant',
            'latitude': 43.65,
            'longitude': -79.38,
            'radius': 100,
        })
        cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'Photo Kiosk User',
            'login': 'nfc-kiosk-photo',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })
        cls.emp = cls.env['hr.employee'].create({
            'name': 'Photo Emp',
            'x_fclk_enable_clock': True,
            'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01',
        })

    def _tap(self, photo_b64=''):
        self.authenticate('nfc-kiosk-photo', 'kioskpass123')
        response = self.url_open(
            '/fusion_clock/kiosk/nfc/tap',
            data=json.dumps({
                'jsonrpc': '2.0', 'method': 'call',
                'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64},
            }),
            headers={'Content-Type': 'application/json'},
        )
        return response.json().get('result', {})

    def test_photo_saved_on_clock_in(self):
        self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
        result = self._tap(self.SAMPLE_PNG_DATAURL)
        self.assertTrue(result.get('success'))
        attendance = self.env['hr.attendance'].search([
            ('employee_id', '=', self.emp.id),
        ], order='check_in desc', limit=1)
        self.assertTrue(attendance.x_fclk_check_in_photo)

    def test_photo_required_rejects_when_missing(self):
        self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
        result = self._tap(photo_b64='')
        self.assertEqual(result.get('error'), 'photo_required')

    def test_photo_optional_succeeds_without_photo(self):
        self.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
        result = self._tap(photo_b64='')
        self.assertTrue(result.get('success'))
  • Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: photo_required rejection test fails because the endpoint doesn't enforce it; photo-saved test fails because photo isn't decoded/saved.

  • Step 3: Add a photo-stripping helper and enforce the requirement

In fusion_clock/controllers/clock_nfc_kiosk.py, after the _is_debounced function, add:

def _strip_data_url_prefix(b64):
    """Strip 'data:image/...;base64,' prefix from a data URL, returning raw base64."""
    if not b64:
        return b''
    if isinstance(b64, str) and b64.startswith('data:'):
        comma = b64.find(',')
        if comma >= 0:
            return b64[comma + 1:].encode('ascii', errors='ignore')
    return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64

Then in the nfc_tap method, immediately AFTER the debounce check and BEFORE resolving the location, add the photo-required gate:

        photo_required = ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
        if photo_required and not photo_b64:
            return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
        photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''

Then, in the clock-in branch (where the existing code does attendance.sudo().write({...x_fclk_clock_source...})), update the dict to include the photo:

            attendance.sudo().write({
                'x_fclk_location_id': location.id,
                'x_fclk_in_distance': 0.0,
                'x_fclk_clock_source': 'nfc_kiosk',
                'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
            })

And in the clock-out branch:

            attendance.sudo().write({
                'x_fclk_out_distance': 0.0,
                'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
            })
  • Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40

Expected: all 3 photo tests pass.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap photo capture + photo-required gate"

Task 12: Employee search endpoint (for Enroll Mode UI)

Files:

  • Modify: fusion_clock/controllers/clock_nfc_kiosk.py (add nfc_employee_search that delegates to existing kiosk search)

  • Modify: fusion_clock/tests/test_clock_nfc_kiosk.py (add TestEmployeeSearch)

  • Step 1: Write the failing test

Append to fusion_clock/tests/test_clock_nfc_kiosk.py:

@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEmployeeSearch(HttpCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.ICP = cls.env['ir.config_parameter'].sudo()
        cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
        cls.kiosk_user = cls.env['res.users'].create({
            'name': 'Search Kiosk User',
            'login': 'nfc-kiosk-search',
            'password': 'kioskpass123',
            'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
        })
        cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True})

    def test_search_returns_matching_employees(self):
        self.authenticate('nfc-kiosk-search', 'kioskpass123')
        response = self.url_open(
            '/fusion_clock/kiosk/nfc/employee_search',
            data=json.dumps({
                'jsonrpc': '2.0', 'method': 'call',
                'params': {'query': 'Steve'},
            }),
            headers={'Content-Type': 'application/json'},
        )
        result = response.json().get('result', {})
        self.assertIn('employees', result)
        names = [e['name'] for e in result['employees']]
        self.assertIn('Searchable Steve', names)
  • Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: 404 on /fusion_clock/kiosk/nfc/employee_search.

  • Step 3: Add the search endpoint

Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the class):

    @http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
    def nfc_employee_search(self, query='', **kw):
        """Delegate to the existing kiosk search to avoid duplication."""
        from .clock_kiosk import FusionClockKiosk
        return FusionClockKiosk().kiosk_search(query=query)
  • Step 4: Run the test to confirm it passes
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30

Expected: search test passes.

  • Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC kiosk employee search endpoint"

Task 13: SCSS styling (always-dark, high-contrast)

Files:

  • Create: fusion_clock/static/src/scss/nfc_kiosk.scss

  • Modify: fusion_clock/__manifest__.py (register the SCSS in web.assets_frontend)

  • Step 1: Create the SCSS file

Create fusion_clock/static/src/scss/nfc_kiosk.scss:

// NFC Clock Kiosk — always-dark, high-contrast.
// Per CLAUDE.md: shop-floor kiosks need explicit hex (no var(--bs-*) which drift)
// and we deliberately do NOT branch on $o-webclient-color-scheme — this is a
// frontend bundle (not backend), and the kiosk is intentionally always dark
// regardless of the user's color scheme preference.

$nfc-bg:            #0b0d10;
$nfc-panel:         #15191f;
$nfc-text:          #ffffff;
$nfc-text-muted:    #9ba3ad;
$nfc-success:       #18a957;
$nfc-error:         #d9374e;
$nfc-accent:        #3b82f6;
$nfc-border:        #2a3038;

html, body {
    background: $nfc-bg !important;
    color: $nfc-text;
    margin: 0;
    padding: 0;
    overflow: hidden;
    height: 100vh;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.o_main_navbar, header, footer { display: none !important; }

.nfc-kiosk {
    width: 100vw;
    height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 2rem;
    box-sizing: border-box;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
}

.nfc-kiosk__company {
    position: absolute;
    top: 1.5rem;
    left: 50%;
    transform: translateX(-50%);
    font-size: 1.25rem;
    color: $nfc-text-muted;
}

.nfc-kiosk__time {
    position: absolute;
    top: 1.5rem;
    right: 2rem;
    font-size: 2rem;
    font-weight: 600;
    color: $nfc-text;
    font-variant-numeric: tabular-nums;
}

.nfc-kiosk__date {
    position: absolute;
    top: 4.5rem;
    right: 2rem;
    font-size: 1rem;
    color: $nfc-text-muted;
}

.nfc-kiosk__location {
    position: absolute;
    bottom: 1.5rem;
    left: 2rem;
    font-size: 0.95rem;
    color: $nfc-text-muted;
}

.nfc-kiosk__settings {
    position: absolute;
    bottom: 1rem;
    right: 1rem;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    background: transparent;
    color: $nfc-text-muted;
    border: 1px solid $nfc-border;
    cursor: pointer;
    font-size: 1.2rem;
    display: flex;
    align-items: center;
    justify-content: center;

    &:hover { color: $nfc-text; }
}

// IDLE state
.nfc-kiosk__idle {
    text-align: center;
}

.nfc-kiosk__icon {
    font-size: 8rem;
    color: $nfc-accent;
    animation: nfc-pulse 2s ease-in-out infinite;
}

@keyframes nfc-pulse {
    0%, 100% { opacity: 0.85; transform: scale(1); }
    50%      { opacity: 1.0;  transform: scale(1.06); }
}

.nfc-kiosk__prompt {
    font-size: 2rem;
    font-weight: 500;
    margin-top: 2rem;
}

// PROCESSING state
.nfc-kiosk__processing {
    text-align: center;
    font-size: 1.5rem;
    color: $nfc-text-muted;
}

// RESULT state — success/error panels
.nfc-kiosk__result {
    width: min(80vw, 700px);
    padding: 2.5rem 3rem;
    border-radius: 1rem;
    display: flex;
    align-items: center;
    gap: 2rem;

    &--success { background: $nfc-success; }
    &--error   { background: $nfc-error; }
}

.nfc-kiosk__avatar {
    width: 7rem;
    height: 7rem;
    border-radius: 50%;
    background-size: cover;
    background-position: center;
    background-color: rgba(255,255,255,0.2);
    flex-shrink: 0;
}

.nfc-kiosk__result-text {
    flex: 1;

    .name { font-size: 2rem; font-weight: 700; }
    .action { font-size: 1.5rem; margin-top: 0.5rem; }
    .hours { font-size: 1.1rem; opacity: 0.9; margin-top: 0.25rem; }
}

// One-time setup wizard
.nfc-kiosk__setup {
    text-align: center;
    max-width: 600px;

    h2 { font-size: 2rem; margin-bottom: 1rem; }
    p  { color: $nfc-text-muted; margin-bottom: 2rem; }

    button {
        font-size: 1.5rem;
        padding: 1rem 3rem;
        background: $nfc-accent;
        color: white;
        border: none;
        border-radius: 0.5rem;
        cursor: pointer;
    }
}

// Enroll Mode
.nfc-kiosk__enroll-overlay {
    position: fixed; inset: 0;
    background: rgba(0,0,0,0.85);
    z-index: 1000;
    display: flex; align-items: center; justify-content: center;
    padding: 2rem;
}

.nfc-kiosk__enroll-panel {
    background: $nfc-panel;
    border: 1px solid $nfc-border;
    border-radius: 1rem;
    padding: 2.5rem;
    width: min(80vw, 700px);

    h2 { font-size: 1.5rem; margin: 0 0 1.5rem; }

    .numpad {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 0.75rem;
        margin: 1rem 0;

        button {
            font-size: 2rem; padding: 1.5rem 0;
            background: $nfc-bg; color: $nfc-text;
            border: 1px solid $nfc-border; border-radius: 0.5rem;
            cursor: pointer;
        }
    }

    .pin-display {
        font-size: 2.5rem; letter-spacing: 0.5rem;
        text-align: center; margin: 1rem 0;
        font-variant-numeric: tabular-nums;
    }

    .employee-search {
        width: 100%;
        padding: 0.75rem 1rem;
        font-size: 1.25rem;
        background: $nfc-bg; color: $nfc-text;
        border: 1px solid $nfc-border;
        border-radius: 0.5rem;
        margin-bottom: 1rem;
    }

    .employee-list {
        max-height: 40vh;
        overflow-y: auto;

        .employee-row {
            padding: 0.75rem 1rem;
            border-bottom: 1px solid $nfc-border;
            cursor: pointer;
            font-size: 1.1rem;

            &:hover { background: $nfc-bg; }
        }
    }

    .actions {
        display: flex; gap: 1rem; justify-content: flex-end;
        margin-top: 1.5rem;

        button {
            font-size: 1rem; padding: 0.75rem 1.5rem;
            border-radius: 0.5rem; cursor: pointer; border: none;
        }
        .cancel  { background: $nfc-border; color: $nfc-text; }
        .confirm { background: $nfc-accent; color: white; }
    }
}
  • Step 2: Register the SCSS in the manifest

Edit fusion_clock/__manifest__.py. In the 'web.assets_frontend' list, add the line for the SCSS file. The block should look like:

        'web.assets_frontend': [
            'fusion_clock/static/src/css/portal_clock.css',
            'fusion_clock/static/src/scss/nfc_kiosk.scss',
            'fusion_clock/static/src/js/fusion_clock_portal.js',
            'fusion_clock/static/src/js/fusion_clock_kiosk.js',
        ],
  • Step 3: Reload module and verify SCSS compiles
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | grep -i -E "(error|warning|nfc_kiosk)" | head -20

Expected: no SCSS compilation errors. If you see "could not be parsed", check for missing semicolons or typos.

  • Step 4: Commit
git add fusion_clock/static/src/scss/nfc_kiosk.scss fusion_clock/__manifest__.py
git commit -m "feat(fusion_clock): NFC kiosk SCSS (always-dark, high-contrast)"

Task 14: Full QWeb template

Files:

  • Modify: fusion_clock/views/kiosk_nfc_templates.xml (replace the placeholder with the full template)

  • Step 1: Replace the placeholder template

Open fusion_clock/views/kiosk_nfc_templates.xml and REPLACE the entire file content with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>

    <template id="nfc_kiosk_page" name="NFC Clock Kiosk">
        <t t-call="web.frontend_layout">
            <t t-set="no_header" t-value="True"/>
            <t t-set="no_footer" t-value="True"/>
            <t t-set="head">
                <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
            </t>

            <div id="nfc_kiosk_root" class="nfc-kiosk"
                 t-att-data-photo-required="'1' if photo_required else '0'"
                 t-att-data-debug-enabled="'1' if debug_enabled else '0'"
                 t-att-data-location-configured="'1' if location_configured else '0'">

                <!-- Static chrome (always visible) -->
                <div class="nfc-kiosk__company" t-esc="company_name"/>
                <div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
                <div class="nfc-kiosk__date" id="nfc_clock_date"></div>
                <div class="nfc-kiosk__location">
                    <span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
                    <span t-else="" style="color:#d9374e">⚠ No location configured</span>
                </div>
                <button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode"></button>

                <!-- Dynamic state container (JS swaps inner HTML) -->
                <div id="nfc_state_container">
                    <!-- Initially: One-time setup wizard step 1 -->
                    <div class="nfc-kiosk__setup">
                        <h2>Welcome to Fusion Clock NFC Kiosk</h2>
                        <p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
                        <button id="nfc_setup_start">Tap to enable NFC reader</button>
                    </div>
                </div>

                <!-- Hidden video element for camera feed -->
                <video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
                       style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
                <canvas id="nfc_camera_canvas" style="display:none;"/>
            </div>
        </t>
    </template>

</odoo>
  • Step 2: Reload module
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
  • Step 3: Smoke test in browser

In Chrome, open http://localhost:8069 and log in as an admin/manager. Then enable the kiosk via odoo-shell:

docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.enable_nfc_kiosk', 'True')
env.cr.commit()
EOF

Navigate to http://localhost:8069/fusion_clock/kiosk/nfc.

Expected: full-screen dark page, "Welcome to Fusion Clock NFC Kiosk" wizard visible, time display in top-right shows "--:--", "Clock at: ..." or warning in bottom-left, ⚙ in bottom-right.

  • Step 4: Commit
git add fusion_clock/views/kiosk_nfc_templates.xml
git commit -m "feat(fusion_clock): NFC kiosk QWeb template"

Task 15: JS scaffold + state machine + IDLE rendering + clock display

Files:

  • Create: fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js

  • Modify: fusion_clock/__manifest__.py (already has web.assets_frontend from Task 13; add the JS)

  • Step 1: Create the JS file with state machine + initial setup binding + clock display

Create fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js:

/* @odoo-module */

// NFC Clock Kiosk — Web NFC + camera + state machine.
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
// element #nfc_kiosk_root only exists on that page, so the module is
// inert elsewhere).

(function() {
    "use strict";

    const root = document.getElementById("nfc_kiosk_root");
    if (!root) return; // not on the kiosk page

    const stateContainer = document.getElementById("nfc_state_container");
    const photoRequired = root.dataset.photoRequired === "1";
    const debugEnabled = root.dataset.debugEnabled === "1";
    const locationConfigured = root.dataset.locationConfigured === "1";

    // ──────────────────────────────────────────────────────────────
    // State machine
    // ──────────────────────────────────────────────────────────────
    const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
    let currentState = STATE.SETUP;

    function setState(next, payload) {
        currentState = next;
        if (next === STATE.IDLE)        renderIdle();
        else if (next === STATE.PROCESSING) renderProcessing();
        else if (next === STATE.RESULT) renderResult(payload);
        else if (next === STATE.ENROLL) renderEnroll(payload);
    }

    // ──────────────────────────────────────────────────────────────
    // Rendering helpers
    // ──────────────────────────────────────────────────────────────
    function renderIdle() {
        stateContainer.innerHTML = `
            <div class="nfc-kiosk__idle">
                <div class="nfc-kiosk__icon">⌐■</div>
                <div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
            </div>
        `;
    }

    function renderProcessing() {
        stateContainer.innerHTML = `
            <div class="nfc-kiosk__processing">Reading card…</div>
        `;
    }

    function renderResult(payload) {
        const isError = payload && payload.error;
        const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";

        if (isError) {
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__result ${cls}">
                    <div class="nfc-kiosk__result-text">
                        <div class="name">${escapeHtml(payload.message || "Error")}</div>
                    </div>
                </div>
            `;
            setTimeout(() => setState(STATE.IDLE), 4000);
        } else {
            const avatar = payload.employee_avatar_url || "";
            const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
            const hours = payload.action === "clock_out" && payload.net_hours_today
                ? `${payload.net_hours_today.toFixed(1)}h today`
                : "";
            const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__result ${cls}">
                    <div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
                    <div class="nfc-kiosk__result-text">
                        <div class="name">${escapeHtml(payload.employee_name)}</div>
                        <div class="action">${action} at ${time}</div>
                        ${hours ? `<div class="hours">${hours}</div>` : ""}
                    </div>
                </div>
            `;
            setTimeout(() => setState(STATE.IDLE), 3000);
        }
    }

    function renderEnroll(payload) {
        // Full implementation lands in Task 18; this stub keeps the state machine valid.
        stateContainer.innerHTML = `<div class="nfc-kiosk__processing">Enroll mode (filled in by Task 18)</div>`;
    }

    function escapeHtml(s) {
        return String(s || "").replace(/[&<>"']/g, c => ({
            "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
        }[c]));
    }

    // ──────────────────────────────────────────────────────────────
    // Clock display (top-right time + date)
    // ──────────────────────────────────────────────────────────────
    function updateClock() {
        const now = new Date();
        const hh = String(now.getHours()).padStart(2, "0");
        const mm = String(now.getMinutes()).padStart(2, "0");
        const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
        const timeEl = document.getElementById("nfc_clock_time");
        const dateEl = document.getElementById("nfc_clock_date");
        if (timeEl) timeEl.textContent = `${hh}:${mm}`;
        if (dateEl) dateEl.textContent = dateStr;
    }
    updateClock();
    setInterval(updateClock, 1000);

    // ──────────────────────────────────────────────────────────────
    // Setup wizard
    // ──────────────────────────────────────────────────────────────
    const setupBtn = document.getElementById("nfc_setup_start");
    if (setupBtn) {
        setupBtn.addEventListener("click", async () => {
            // Web NFC + camera activation lives in Tasks 16 + 17
            setState(STATE.IDLE);
        });
    }

    // Expose a tiny API for later tasks
    window.__nfcKiosk = {
        setState,
        STATE,
        photoRequired,
        debugEnabled,
        locationConfigured,
    };
})();
  • Step 2: Register the JS in the manifest

Edit fusion_clock/__manifest__.py. The web.assets_frontend list should now include both the SCSS (from Task 13) and the JS:

        'web.assets_frontend': [
            'fusion_clock/static/src/css/portal_clock.css',
            'fusion_clock/static/src/scss/nfc_kiosk.scss',
            'fusion_clock/static/src/js/fusion_clock_portal.js',
            'fusion_clock/static/src/js/fusion_clock_kiosk.js',
            'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
        ],
  • Step 3: Reload + smoke test in browser
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5

Hard-refresh http://localhost:8069/fusion_clock/kiosk/nfc (Ctrl+Shift+R). Expected:

  • Wizard "Welcome to Fusion Clock NFC Kiosk" visible

  • Click "Tap to enable NFC reader" → page transitions to IDLE state showing the NFC icon and "Tap your card to clock in or out"

  • Top-right time updates every second

  • Step 4: Commit

git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js fusion_clock/__manifest__.py
git commit -m "feat(fusion_clock): NFC kiosk JS scaffold + state machine + clock display"

Task 16: Web NFC integration (NDEFReader scan + reading event)

Files:

  • Modify: fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js (add NFC scanning + tap dispatcher)

  • Step 1: Add NFC scanning to the setup activation handler

In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the setup wizard click handler (the setupBtn.addEventListener("click", async () => { setState(STATE.IDLE); });) and the trailing window.__nfcKiosk = {...} block with:

    // ──────────────────────────────────────────────────────────────
    // Web NFC reader
    // ──────────────────────────────────────────────────────────────
    let ndefReader = null;
    let nfcReady = false;

    async function startNfcReader() {
        if (!("NDEFReader" in window)) {
            throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
        }
        ndefReader = new NDEFReader();
        await ndefReader.scan();
        ndefReader.addEventListener("reading", onNfcReading);
        ndefReader.addEventListener("readingerror", () => {
            console.warn("[nfc-kiosk] reading error; reader still active");
        });
        nfcReady = true;
    }

    function onNfcReading(event) {
        // event.serialNumber is the card UID — works for raw MIFARE access cards
        const uid = (event.serialNumber || "").toUpperCase();
        if (!uid) return;
        if (currentState === STATE.ENROLL) {
            // Enroll Mode handles taps differently (wired up in Task 18)
            window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
            return;
        }
        if (currentState !== STATE.IDLE) return; // ignore taps mid-result
        handleTap(uid);
    }

    async function handleTap(uid) {
        setState(STATE.PROCESSING);
        let photoB64 = "";
        try {
            photoB64 = await capturePhoto();
        } catch (e) {
            console.warn("[nfc-kiosk] camera capture failed", e);
            // Server enforces photo_required if needed
        }
        try {
            const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
            if (result.error === "debounce") {
                // silent — back to IDLE
                setState(STATE.IDLE);
                return;
            }
            setState(STATE.RESULT, result);
        } catch (e) {
            setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
        }
    }

    async function postJson(url, params) {
        const res = await fetch(url, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
        });
        const json = await res.json();
        return json.result || {};
    }

    // ──────────────────────────────────────────────────────────────
    // Camera capture (real implementation in Task 17, stub for now)
    // ──────────────────────────────────────────────────────────────
    async function capturePhoto() {
        return ""; // overridden in Task 17
    }

    // ──────────────────────────────────────────────────────────────
    // Setup wizard activation
    // ──────────────────────────────────────────────────────────────
    const setupBtn = document.getElementById("nfc_setup_start");
    if (setupBtn) {
        setupBtn.addEventListener("click", async () => {
            try {
                await startNfcReader();
                setState(STATE.IDLE);
            } catch (e) {
                stateContainer.innerHTML = `
                    <div class="nfc-kiosk__setup">
                        <h2 style="color:#d9374e">Setup failed</h2>
                        <p>${escapeHtml(e.message)}</p>
                    </div>
                `;
            }
        });
    }

    window.__nfcKiosk = {
        setState, STATE, photoRequired, debugEnabled, locationConfigured,
        handleTap, // exposed for mock-tap debug (Task 19)
    };
  • Step 2: Reload + verify in Chrome on Android
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5

Open the kiosk URL in Chrome on an Android phone (HTTPS required — see note below). Click "Tap to enable NFC reader". Browser will prompt for NFC permission → grant it. Tap any contactless card.

HTTPS requirement: Web NFC requires a secure origin. For dev, either:

  1. Use http://localhost directly on the device (NOT supported for NFC on Android — Android requires HTTPS even for localhost)
  2. Set up ngrok http 8069 to get an HTTPS tunnel, or
  3. Use the production HTTPS domain Without HTTPS the await ndefReader.scan() call throws.

Expected: tap fires → page enters PROCESSING → server returns card_unknown (no enrollment yet) → red RESULT screen "Card not recognized. See your manager." → back to IDLE after 4s.

  • Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): Web NFC integration on kiosk page"

Task 17: Camera capture (getUserMedia + canvas frame grab)

Files:

  • Modify: fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js (replace the capturePhoto stub with a real implementation; activate the camera as part of setup)

  • Step 1: Add camera activation + real capture

In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the entire capturePhoto stub (async function capturePhoto() { return ""; }) and the startNfcReader function with:

    // ──────────────────────────────────────────────────────────────
    // Camera
    // ──────────────────────────────────────────────────────────────
    let cameraStream = null;
    const videoEl = document.getElementById("nfc_camera_feed");
    const canvasEl = document.getElementById("nfc_camera_canvas");

    async function startCamera() {
        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
            throw new Error("Camera not supported on this browser/device.");
        }
        cameraStream = await navigator.mediaDevices.getUserMedia({
            video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
            audio: false,
        });
        videoEl.srcObject = cameraStream;
        await videoEl.play();
    }

    async function capturePhoto() {
        if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
        const w = videoEl.videoWidth;
        const h = videoEl.videoHeight;
        canvasEl.width = w;
        canvasEl.height = h;
        const ctx = canvasEl.getContext("2d");
        ctx.drawImage(videoEl, 0, 0, w, h);
        // ~30-60 KB at quality 0.7 for 640x480
        return canvasEl.toDataURL("image/jpeg", 0.7);
    }

    async function startNfcReader() {
        if (!("NDEFReader" in window)) {
            throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
        }
        ndefReader = new NDEFReader();
        await ndefReader.scan();
        ndefReader.addEventListener("reading", onNfcReading);
        ndefReader.addEventListener("readingerror", () => {
            console.warn("[nfc-kiosk] reading error; reader still active");
        });
        nfcReady = true;
    }

Then UPDATE the setup button click handler to also start the camera:

    if (setupBtn) {
        setupBtn.addEventListener("click", async () => {
            try {
                await startNfcReader();
                try {
                    await startCamera();
                } catch (camErr) {
                    if (photoRequired) throw camErr;
                    console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
                }
                setState(STATE.IDLE);
            } catch (e) {
                stateContainer.innerHTML = `
                    <div class="nfc-kiosk__setup">
                        <h2 style="color:#d9374e">Setup failed</h2>
                        <p>${escapeHtml(e.message)}</p>
                    </div>
                `;
            }
        });
    }
  • Step 2: Reload + smoke test on a real device
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5

On the Android phone with NFC, hard-refresh the kiosk page. Click activation button. Browser prompts for NFC, then for Camera. Grant both. Tap a contactless card.

Expected: tap fires, page processes, server returns card_unknown (no enrollment yet) — but check that the tap payload includes photo_b64 (open browser DevTools → Network tab → inspect the /tap request body; it should contain a data:image/jpeg;base64,... value).

  • Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): camera capture on NFC kiosk"

Task 18: Enroll Mode UI

Files:

  • Modify: fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js (add the full Enroll Mode flow: ⚙ button → numpad password → employee picker → tap-to-enroll)

  • Step 1: Add the Enroll Mode flow

In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the placeholder renderEnroll(payload) function with the full implementation, AND add the supporting variables and the ⚙ click handler. Insert the new code immediately after the existing renderResult(payload) function:

    // ──────────────────────────────────────────────────────────────
    // Enroll Mode
    // ──────────────────────────────────────────────────────────────
    let enrollPassword = "";
    let enrollSelectedEmployee = null;
    let enrollIdleTimer = null;

    function resetEnrollIdleTimer() {
        if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
        enrollIdleTimer = setTimeout(() => {
            // 60s of inactivity in Enroll Mode → exit
            exitEnrollMode();
        }, 60000);
    }

    function exitEnrollMode() {
        if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
        enrollIdleTimer = null;
        enrollPassword = "";
        enrollSelectedEmployee = null;
        setState(STATE.IDLE);
    }

    function renderEnroll(payload) {
        const phase = (payload && payload.phase) || "password";
        resetEnrollIdleTimer();

        if (phase === "password") {
            const masked = "•".repeat(enrollPassword.length);
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__enroll-overlay">
                    <div class="nfc-kiosk__enroll-panel">
                        <h2>Enter Enroll Mode Password</h2>
                        <div class="pin-display">${masked}</div>
                        <div class="numpad">
                            ${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
                            <button data-n="back">⌫</button>
                            <button data-n="0">0</button>
                            <button data-n="ok">OK</button>
                        </div>
                        <div class="actions">
                            <button class="cancel" id="enroll_cancel">Cancel</button>
                        </div>
                    </div>
                </div>
            `;
            stateContainer.querySelectorAll(".numpad button").forEach(btn => {
                btn.addEventListener("click", async () => {
                    resetEnrollIdleTimer();
                    const n = btn.dataset.n;
                    if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
                    else if (n === "ok") {
                        // Try to advance to employee picker — server validates the password
                        // when the actual enroll request is made; here we just trust the
                        // user entered something and move on. If the enroll later fails
                        // with invalid_password we reset.
                        if (enrollPassword.length === 0) return;
                        renderEnroll({ phase: "search" });
                        return;
                    }
                    else enrollPassword += n;
                    renderEnroll({ phase: "password" });
                });
            });
            document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
            return;
        }

        if (phase === "search") {
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__enroll-overlay">
                    <div class="nfc-kiosk__enroll-panel">
                        <h2>Pick the employee to enroll</h2>
                        <input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
                        <div class="employee-list" id="enroll_list"></div>
                        <div class="actions">
                            <button class="cancel" id="enroll_cancel">Cancel</button>
                        </div>
                    </div>
                </div>
            `;
            const searchEl = document.getElementById("enroll_search");
            const listEl = document.getElementById("enroll_list");
            let debounceTimer = null;
            searchEl.addEventListener("input", () => {
                resetEnrollIdleTimer();
                if (debounceTimer) clearTimeout(debounceTimer);
                debounceTimer = setTimeout(async () => {
                    const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
                    listEl.innerHTML = (result.employees || []).map(e =>
                        `<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
                    ).join("");
                    listEl.querySelectorAll(".employee-row").forEach(row => {
                        row.addEventListener("click", () => {
                            enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
                            renderEnroll({ phase: "tap" });
                        });
                    });
                }, 200);
            });
            searchEl.focus();
            document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
            return;
        }

        if (phase === "tap") {
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__enroll-overlay">
                    <div class="nfc-kiosk__enroll-panel" style="text-align:center">
                        <h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
                        <div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
                        <p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
                        <div class="actions">
                            <button class="cancel" id="enroll_cancel">Cancel</button>
                        </div>
                    </div>
                </div>
            `;
            document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
            return;
        }

        if (phase === "result") {
            const ok = !payload.error;
            const msg = ok
                ? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
                : (payload.error === "invalid_password"
                    ? "Wrong password. Try again."
                    : payload.error === "card_already_assigned"
                        ? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
                        : `Enroll failed: ${escapeHtml(payload.error)}`);
            stateContainer.innerHTML = `
                <div class="nfc-kiosk__enroll-overlay">
                    <div class="nfc-kiosk__enroll-panel" style="text-align:center">
                        <h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
                        <div class="actions" style="justify-content:center">
                            <button class="confirm" id="enroll_another">Enroll another</button>
                            <button class="cancel" id="enroll_done">Done</button>
                        </div>
                    </div>
                </div>
            `;
            document.getElementById("enroll_another").addEventListener("click", () => {
                enrollSelectedEmployee = null;
                renderEnroll({ phase: ok ? "search" : "password" });
            });
            document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
        }
    }

    async function _onEnrollTap(uid) {
        if (!enrollSelectedEmployee) return;
        const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
            employee_id: enrollSelectedEmployee.id,
            card_uid: uid,
            enroll_password: enrollPassword,
        });
        renderEnroll({ phase: "result", ...result });
    }

    // ⚙ button → enter Enroll Mode
    const settingsBtn = document.getElementById("nfc_settings_btn");
    if (settingsBtn) {
        settingsBtn.addEventListener("click", () => {
            if (currentState !== STATE.IDLE) return;
            enrollPassword = "";
            enrollSelectedEmployee = null;
            setState(STATE.ENROLL, { phase: "password" });
        });
    }

Then UPDATE the window.__nfcKiosk exposure at the bottom to include the enroll callback:

    window.__nfcKiosk = {
        setState, STATE, photoRequired, debugEnabled, locationConfigured,
        handleTap, _onEnrollTap,
    };
  • Step 2: Reload + smoke test on real device
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5

Set the enroll password:

docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1234')
env.cr.commit()
EOF

On the kiosk page, click ⚙ → enter 1234 → click OK → search for an employee → click their name → tap a card. Expected: success result panel with "✓ Card XX:XX:... enrolled to ". Click "Done" → returns to IDLE. Then tap the same card → should clock that employee in.

  • Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): NFC kiosk Enroll Mode UI"

Task 19: Mock-tap debug shortcut

Files:

  • Modify: fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js (add Ctrl+Shift+T handler when debug flag is set)

  • Step 1: Add the debug keyboard shortcut

In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, immediately BEFORE the final window.__nfcKiosk = {...} block, add:

    // ──────────────────────────────────────────────────────────────
    // Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
    // ──────────────────────────────────────────────────────────────
    if (debugEnabled) {
        document.addEventListener("keydown", (e) => {
            if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
                e.preventDefault();
                const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
                const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
                if (!uid) return;
                localStorage.setItem("nfc_mock_uid", uid);
                if (currentState === STATE.ENROLL) {
                    _onEnrollTap(uid.toUpperCase());
                } else if (currentState === STATE.IDLE) {
                    handleTap(uid.toUpperCase());
                }
            }
        });
        console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
    }
  • Step 2: Enable debug mode and verify
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_kiosk_debug', 'True')
env.cr.commit()
EOF

Hard-refresh the kiosk page. Click activation. Once at IDLE, press Ctrl+Shift+T. A prompt asks for a UID. Enter any enrolled UID. Expected: page enters PROCESSING → RESULT showing the employee's name.

Note: nfc_kiosk_debug defaults to False — must be explicitly toggled on. The shortcut handler is only attached when the flag is True at page-render time.

  • Step 3: Disable debug mode again
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_kiosk_debug', 'False')
env.cr.commit()
EOF

Refresh. Confirm Ctrl+Shift+T no longer fires the prompt.

  • Step 4: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): NFC kiosk mock-tap debug shortcut"

Task 20: Manifest version bump + final smoke test

Files:

  • Modify: fusion_clock/__manifest__.py (bump version)

  • Step 1: Bump the module version

In fusion_clock/__manifest__.py, change:

    'version': '19.0.2.0.0',

to:

    'version': '19.0.3.0.0',

(The bump signals to Odoo to invalidate cached asset bundles. Per CLAUDE.md, this is the cleanest cache-bust.)

  • Step 2: Reload one final time
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -10

If the asset cache appears stale (CSS not updating, old JS), follow the CLAUDE.md escalation:

docker exec -it odoo-modsdev-app psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init

Then in the browser, DevTools → right-click refresh → "Empty Cache and Hard Reload".

  • Step 3: Run the full test suite one last time
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -50

Expected: 0 failures, 0 errors across all NFC tests.

  • Step 4: End-to-end manual smoke test on Android phone

On an Android phone with NFC, in Chrome over HTTPS:

  1. Navigate to https://<odoo-domain>/fusion_clock/kiosk/nfc
  2. Log in as the kiosk service user
  3. Click "Tap to enable NFC reader" — grant NFC + camera permissions
  4. Confirm IDLE state shows
  5. Click ⚙ → enter enroll password → search for an employee → tap a contactless credit card to enroll it
  6. Confirm "✓ Card XX:XX:... enrolled" appears
  7. Click "Done" → tap the same card again → confirm RESULT screen shows "Welcome — CLOCKED IN"
  8. Wait 6+ seconds, tap again → confirm RESULT shows "CLOCKED OUT"
  9. In Odoo backend, navigate to Attendances, find the new record, confirm Clock Source = NFC Kiosk and the photo fields are populated
  • Step 5: Commit the version bump
git add fusion_clock/__manifest__.py
git commit -m "chore(fusion_clock): bump version to 19.0.3.0.0 for NFC kiosk feature"

Self-Review Checklist (run before declaring complete)

  • Every spec section in 2026-05-13-nfc-clock-kiosk-design.md has a corresponding task above
  • No placeholder text in any task — every step shows actual code or commands
  • Field names used in tests match field names defined in tasks (x_fclk_nfc_card_uid, x_fclk_nfc_kiosk_location_id, x_fclk_check_in_photo, x_fclk_check_out_photo, x_fclk_clock_source extended)
  • Endpoint URLs are consistent: /fusion_clock/kiosk/nfc, /fusion_clock/kiosk/nfc/tap, /fusion_clock/kiosk/nfc/enroll, /fusion_clock/kiosk/nfc/employee_search
  • Error code strings are consistent: card_unknown, clock_disabled, no_location_configured, kiosk_disabled, invalid_uid, debounce, photo_required, invalid_password, card_already_assigned, employee_not_found, access_denied
  • Manifest registers all new XML and JS files
  • Tests are tagged fusion_clock so the --test-tags fusion_clock command picks them up
  • Module version bumped at the end