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:
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from . import wizard
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
5
fusion_clock/wizard/__init__.py
Normal file
5
fusion_clock/wizard/__init__.py
Normal 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
|
||||||
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal file
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal 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 & 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 & 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>
|
||||||
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal file
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal 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',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user