feat(fusion_clock): NFC card enrollment wizard + employee form field

Adds a tap-driven enrollment workflow so managers can pair NFC/RFID
cards to employees using a USB HID reader at their desk:

- New wizard model fusion.clock.nfc.enrollment.wizard with auto-focused
  Card UID field, employee picker, and reassignment warning if the
  card is already held by someone else.
- Two actions: 'Enroll Card' (single) and 'Enroll & Next' (bulk).
- Menu entry under Fusion Clock root, manager-gated.
- Exposes x_fclk_nfc_card_uid on the Employee form Clock Settings
  section (next to Kiosk PIN) so it can be inspected/edited directly.
- Bumps manifest to 19.0.3.1.0 for asset cache bust.

Wizard reuses FusionClockNfcKiosk._normalize_uid so stored format
matches what the kiosk /tap endpoint looks up later. Reassignment
clears the UID from the previous holder and logs both events to the
activity log under 'card_enrollment'.
This commit is contained in:
gsinghpal
2026-05-15 18:55:42 -04:00
parent 152ed86c3a
commit c2646f59c4
7 changed files with 237 additions and 1 deletions

View File

@@ -4,3 +4,4 @@
from . import models from . import models
from . import controllers from . import controllers
from . import wizard

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.3.0.0', 'version': '19.0.3.1.0',
'category': 'Human Resources/Attendances', 'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """ 'description': """
@@ -77,6 +77,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/portal_report_templates.xml', 'views/portal_report_templates.xml',
'views/kiosk_templates.xml', 'views/kiosk_templates.xml',
'views/kiosk_nfc_templates.xml', 'views/kiosk_nfc_templates.xml',
# Wizards
'wizard/clock_nfc_enrollment_views.xml',
], ],
'assets': { 'assets': {
'web.assets_frontend': [ 'web.assets_frontend': [

View File

@@ -97,6 +97,14 @@
sequence="50" sequence="50"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/> groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- NFC Card Enrollment Wizard -->
<menuitem id="menu_fusion_clock_nfc_enrollment"
name="Enroll NFC Card"
parent="menu_fusion_clock_root"
action="action_fusion_clock_nfc_enrollment_wizard"
sequence="55"
groups="group_fusion_clock_manager"/>
<!-- Configuration Sub-Menu --> <!-- Configuration Sub-Menu -->
<menuitem id="menu_fusion_clock_config" <menuitem id="menu_fusion_clock_config"
name="Configuration" name="Configuration"

View File

@@ -20,6 +20,9 @@
<field name="x_fclk_break_minutes"/> <field name="x_fclk_break_minutes"/>
<field name="x_fclk_kiosk_pin" password="True" <field name="x_fclk_kiosk_pin" password="True"
groups="fusion_clock.group_fusion_clock_manager"/> groups="fusion_clock.group_fusion_clock_manager"/>
<field name="x_fclk_nfc_card_uid"
placeholder="Tap card on USB reader, or paste UID"
groups="fusion_clock.group_fusion_clock_manager"/>
</group> </group>
<group string="Status"> <group string="Status">
<field name="x_fclk_ontime_streak"/> <field name="x_fclk_ontime_streak"/>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import clock_nfc_enrollment_wizard

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Enrollment Wizard Form -->
<record id="view_fusion_clock_nfc_enrollment_wizard_form" model="ir.ui.view">
<field name="name">fusion.clock.nfc.enrollment.wizard.form</field>
<field name="model">fusion.clock.nfc.enrollment.wizard</field>
<field name="arch" type="xml">
<form string="Enroll NFC Card">
<sheet>
<div class="alert alert-info" role="alert">
<strong>How to enroll:</strong> Tap an NFC card on the USB reader connected
to this computer. The reader will type the UID into the field below.
Then select the employee and click <b>Enroll Card</b> (or
<b>Enroll &amp; Next</b> to keep enrolling).
</div>
<group>
<field name="card_uid"
placeholder="Tap card on reader, or paste UID manually"/>
<field name="normalized_uid"
invisible="not normalized_uid"
readonly="1"/>
<field name="warning_message"
invisible="not warning_message"
readonly="1"
nolabel="1"
colspan="2"/>
<field name="existing_employee_id" invisible="1"/>
<field name="employee_id"
options="{'no_create': True, 'no_create_edit': True}"/>
</group>
</sheet>
<footer>
<button name="action_enroll"
string="Enroll Card"
type="object"
class="btn-primary"
invisible="not normalized_uid or not employee_id"/>
<button name="action_enroll_and_next"
string="Enroll &amp; Next"
type="object"
class="btn-secondary"
invisible="not normalized_uid or not employee_id"/>
<button special="cancel"
string="Cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Action to open the wizard -->
<record id="action_fusion_clock_nfc_enrollment_wizard" model="ir.actions.act_window">
<field name="name">Enroll NFC Card</field>
<field name="res_model">fusion.clock.nfc.enrollment.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fusion_clock_nfc_enrollment_wizard_form"/>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..controllers.clock_nfc_kiosk import FusionClockNfcKiosk
class FusionClockNfcEnrollmentWizard(models.TransientModel):
"""Tap-driven NFC card enrollment for the fusion_clock kiosk.
Flow:
1. Manager opens this wizard.
2. Card UID field is auto-focused.
3. Manager taps an NFC card on the USB HID reader; the reader types the
UID into the focused field and hits Enter, which advances focus to
the Employee picker.
4. Manager selects the employee.
5. Manager clicks "Enroll Card" (closes wizard) or "Enroll & Next"
(resets wizard for the next card).
The wizard reuses ``FusionClockNfcKiosk._normalize_uid`` so the stored
format matches whatever the kiosk's ``/tap`` endpoint will look up later.
"""
_name = 'fusion.clock.nfc.enrollment.wizard'
_description = 'NFC Card Enrollment Wizard'
card_uid = fields.Char(
string='Card UID',
required=True,
help='Tap an NFC card on the USB reader. Most HID readers type the '
'UID followed by Enter, which advances focus to the Employee '
'field below. You can also paste a UID manually.',
)
normalized_uid = fields.Char(
string='Normalized UID',
compute='_compute_normalized_uid',
store=False,
help='UID after format normalization (uppercase, colon-separated hex). '
'This is what gets stored on the employee record.',
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
domain=[('x_fclk_enable_clock', '=', True)],
)
existing_employee_id = fields.Many2one(
'hr.employee',
string='Currently Assigned To',
compute='_compute_existing_employee',
store=False,
)
warning_message = fields.Char(
compute='_compute_existing_employee',
store=False,
)
@api.depends('card_uid')
def _compute_normalized_uid(self):
for wiz in self:
wiz.normalized_uid = FusionClockNfcKiosk._normalize_uid(wiz.card_uid) or ''
@api.depends('normalized_uid', 'employee_id')
def _compute_existing_employee(self):
for wiz in self:
if not wiz.normalized_uid:
wiz.existing_employee_id = False
wiz.warning_message = ''
continue
existing = self.env['hr.employee'].sudo().search([
('x_fclk_nfc_card_uid', '=', wiz.normalized_uid),
], limit=1)
if existing and existing != wiz.employee_id:
wiz.existing_employee_id = existing
wiz.warning_message = _(
"⚠ This card is currently assigned to %(name)s. "
"Enrolling will reassign it.",
name=existing.name,
)
else:
wiz.existing_employee_id = False
wiz.warning_message = ''
def action_enroll(self):
"""Enroll the card to the selected employee and close the wizard."""
self.ensure_one()
self._do_enroll()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Card Enrolled'),
'message': _("%(uid)s assigned to %(name)s.",
uid=self.normalized_uid, name=self.employee_id.name),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def action_enroll_and_next(self):
"""Enroll the card, then reopen the wizard cleared for the next card."""
self.ensure_one()
self._do_enroll()
return {
'type': 'ir.actions.act_window',
'name': _('Enroll NFC Card'),
'res_model': self._name,
'view_mode': 'form',
'target': 'new',
'context': {}, # explicitly empty so no defaults persist
}
def _do_enroll(self):
"""Validate, then write the normalized UID to the employee record.
Reassigns the card from any existing holder. Logs the event in the
activity log for audit.
"""
if not self.normalized_uid:
raise ValidationError(_(
"Card UID is empty or not valid hex. Tap the card again on "
"the reader."
))
if not self.employee_id:
raise ValidationError(_("Please select an employee."))
# Reassignment: clear the UID from whoever currently holds it
if self.existing_employee_id and self.existing_employee_id != self.employee_id:
self.existing_employee_id.sudo().x_fclk_nfc_card_uid = False
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.existing_employee_id.id,
'log_type': 'card_enrollment',
'description': _(
"NFC card %(uid)s unassigned by %(user)s (reassigning to %(new)s)",
uid=self.normalized_uid,
user=self.env.user.name,
new=self.employee_id.name,
),
'source': 'nfc_kiosk',
})
self.employee_id.sudo().x_fclk_nfc_card_uid = self.normalized_uid
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.employee_id.id,
'log_type': 'card_enrollment',
'description': _(
"NFC card %(uid)s enrolled by %(user)s",
uid=self.normalized_uid, user=self.env.user.name,
),
'source': 'nfc_kiosk',
})