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>
162 lines
6.5 KiB
Python
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)
|