Files
gsinghpal 0f41eb136d fix(employee): handle Odoo 19 'in' operator + empty-list sentinel
Two compounding bugs in _search_x_fc_is_clocked_in surfaced when
fusion_clock's auto-clock-out closed all the demo open attendances:

  1. Odoo 19 normalises ('=', True) into ('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-
     open-attendance case correctly matches nothing.

Rewrite reduces caller intent to a match_set of booleans, flips on
negative operators, then emits id IN / NOT IN against the cached
open-attendance employee ids. Variable signature accepts Odoo's
3-arg (records, op, val) form too in case the API shifts.

Verified on entech: clocked_in==True returns 3 (Carlos, James,
Marie); ==False returns the other 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:05:11 -04:00

162 lines
6.5 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class HrEmployee(models.Model):
"""Tag employees with the shop roles they can perform.
An employee with role 'masking' receives the masking steps when WOs
are generated; an employee with multiple roles receives WOs for all
of them. A small shop where the owner wears every hat just tags
themselves with every role.
Lead hands are a separate per-role list — they don't have to be
primary owners of those roles, but they're authorised to step in
when the regular owner is absent or behind. The Manager Desk
promotes lead hands above other workers in its dropdown for any
role they cover.
"""
_inherit = 'hr.employee'
x_fc_work_role_ids = fields.Many2many(
'fp.work.role', 'fp_employee_work_role_rel',
'employee_id', 'role_id', string='Shop Roles',
help='Which shop roles this employee performs. Used by the '
'Manager Desk and auto-assignment on WO generation. '
'Roles are added automatically when an employee completes '
'a task that meets the role mastery threshold.',
)
# 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.
Two compounding gotchas surfaced after fusion_clock auto-closed
the demo open attendances:
1. Odoo 19 normalises ``('=', True)`` into
``('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 every
row.
2. ``('id', 'in', [])`` is also treated as no-constraint in
some Odoo versions; replaced with a ``[0]`` sentinel so
the empty-open-list case correctly matches nothing.
Strategy: reduce caller intent to a *match_set* of booleans
(which values of ``x_fc_is_clocked_in`` should match), flip on
negative operators, then translate into ``id IN`` / ``NOT IN``
on the cached open-attendance employee ids. Variable signature
future-proofs against Odoo's compute-field API shifting again.
"""
# 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)]
if operator in ('=', '!='):
match_set = {bool(value)}
elif operator in ('in', 'not in'):
match_set = set(map(bool, value))
else:
return [('id', '=', False)]
# Negated operators flip the match set.
if operator in ('!=', 'not in'):
match_set = {True, False} - match_set
if not match_set:
return [('id', '=', False)]
if match_set == {True, False}:
return [] # every row matches
open_emp_ids = Att.sudo().search(
[('check_out', '=', False)]
).employee_id.ids
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)