fix(employee): handle Odoo 19 'in' operator + empty-list sentinel in clocked-in search
Two compounding bugs in _search_x_fc_is_clocked_in surfaced when
fusion_clock's auto-clock-out closed all demo open attendances:
1. Odoo 19 normalises ('=', True) to ('in', OrderedSet([True]))
before invoking the search method. The previous code only
handled '=' / '!=' and fell through to return [] for 'in' /
'not in' — which Odoo treats as 'no constraint' and matches
the entire table.
2. ('id', 'in', []) is also treated as no-constraint in some
Odoo versions; replaced with a [0] sentinel so the empty
case correctly matches nothing.
Rewrite reduces caller intent to a match_set of booleans, flips it
on negative operators, then emits id IN / NOT IN against the cached
open-attendance employee ids. Accepts a 3-arg signature too in case
Odoo's compute-field calling convention shifts again.
Verified on entech: clocked_in==True returns the 3 currently-on-shift
operators (Carlos, James, Marie); ==False returns the other 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,3 +22,136 @@ class HrEmployee(models.Model):
|
|||||||
help='Which shop roles this employee performs. Used by the '
|
help='Which shop roles this employee performs. Used by the '
|
||||||
'Manager Desk and auto-assignment on WO generation.',
|
'Manager Desk and auto-assignment on WO generation.',
|
||||||
)
|
)
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
=======
|
||||||
|
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||||
|
# racking but not for plating; Mike might cover everything during
|
||||||
|
# a graveyard shift. Stored on a separate relation table so the
|
||||||
|
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||||
|
# authority.
|
||||||
|
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||||
|
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||||
|
'employee_id', 'role_id', string='Lead Hand For',
|
||||||
|
help='Roles where this employee is authorised to lead or cover '
|
||||||
|
'for an absent operator. Lead hands are surfaced first in '
|
||||||
|
'the Manager Desk worker picker for these roles.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_proficiency_ids = fields.One2many(
|
||||||
|
'fp.operator.proficiency', 'employee_id',
|
||||||
|
string='Task Proficiency',
|
||||||
|
help='Per-role completion tally. Workers earn one count per WO '
|
||||||
|
'they finish on a given role. Once the count crosses the '
|
||||||
|
"role's mastery threshold the role is added to their "
|
||||||
|
'Shop Roles list automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attendance helpers — used by the Manager Desk to show who is
|
||||||
|
# currently clocked in. Works with vanilla hr_attendance or the
|
||||||
|
# full fusion_clock module — both store an open record (no
|
||||||
|
# check_out) for as long as the employee is on shift.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_is_clocked_in = fields.Boolean(
|
||||||
|
string='Clocked In',
|
||||||
|
compute='_compute_x_fc_is_clocked_in',
|
||||||
|
search='_search_x_fc_is_clocked_in',
|
||||||
|
help='True if this employee currently has an open hr.attendance '
|
||||||
|
'record (clocked in but not clocked out).',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_x_fc_is_clocked_in(self):
|
||||||
|
"""Compute attendance status from hr.attendance.
|
||||||
|
|
||||||
|
Batched so the manager dashboard doesn't issue one query per
|
||||||
|
employee — important when the shop has dozens of operators.
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
return
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = False
|
||||||
|
return
|
||||||
|
# One read for the whole recordset.
|
||||||
|
open_emp_ids = set(Att.sudo().search([
|
||||||
|
('employee_id', 'in', self.ids),
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id').ids)
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||||
|
|
||||||
|
def _search_x_fc_is_clocked_in(self, *args):
|
||||||
|
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
|
||||||
|
|
||||||
|
Odoo 19 normalises the equality term ``('=', True)`` into
|
||||||
|
``('in', OrderedSet([True]))`` before calling this method, so
|
||||||
|
we have to handle ``in`` / ``not in`` as well as the bare
|
||||||
|
``=`` / ``!=``. The signature is also variadic so a future
|
||||||
|
Odoo refactor that prepends a ``records`` argument doesn't
|
||||||
|
break us.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Reduce the caller's intent to a *match_set* of booleans
|
||||||
|
— which values of ``x_fc_is_clocked_in`` should match.
|
||||||
|
2. Negative operators flip that set.
|
||||||
|
3. Translate the set into an ``id IN`` (or ``NOT IN``) term
|
||||||
|
on the cached open-attendance employee ids.
|
||||||
|
"""
|
||||||
|
# Variable signature — Odoo 19 may pass (records, op, val).
|
||||||
|
if len(args) == 3:
|
||||||
|
_records, operator, value = args
|
||||||
|
elif len(args) == 2:
|
||||||
|
operator, value = args
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
# Build the set of bool values the caller wants.
|
||||||
|
if operator in ('=', '!='):
|
||||||
|
match_set = {bool(value)}
|
||||||
|
elif operator in ('in', 'not in'):
|
||||||
|
# value is a (possibly OrderedSet) iterable of bools.
|
||||||
|
match_set = set(map(bool, value))
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
# Negated operators flip the match set so we can reason in
|
||||||
|
# purely positive terms below.
|
||||||
|
if operator in ('!=', 'not in'):
|
||||||
|
match_set = {True, False} - match_set
|
||||||
|
|
||||||
|
if not match_set:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
if match_set == {True, False}:
|
||||||
|
# No filter at all — every employee matches.
|
||||||
|
return []
|
||||||
|
|
||||||
|
open_emp_ids = Att.sudo().search(
|
||||||
|
[('check_out', '=', False)]
|
||||||
|
).employee_id.ids
|
||||||
|
# Sentinel guards against an empty list — Odoo treats
|
||||||
|
# `('id', 'in', [])` as no constraint in some versions and
|
||||||
|
# ends up matching every row.
|
||||||
|
ids_term = open_emp_ids or [0]
|
||||||
|
return [('id', 'in' if True in match_set else 'not in', ids_term)]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fp_clocked_in_user_ids(self):
|
||||||
|
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||||
|
|
||||||
|
Used by the Manager Desk controller to short-circuit the worker
|
||||||
|
dropdown to "present today" without an N+1 attendance query
|
||||||
|
per worker.
|
||||||
|
"""
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return set()
|
||||||
|
emps = Att.sudo().search([
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id')
|
||||||
|
return set(emps.user_id.ids)
|
||||||
|
>>>>>>> Stashed changes
|
||||||
|
|||||||
Reference in New Issue
Block a user