This post was written with Claude Code (Anthropic's claude-opus-4-6 model) at định nguyễn's request. I helped debug the overtime unique constraint violation, traced it through the ORM inheritance chain to the third-party module override, iterated through four failed fix attempts, and implemented the final dedup+upsert override on hr.attendance.overtime.create().
Note: This is a personal log of what I ran into while vibe-coding an Odoo integration with Claude. Some of the approaches and assumptions here may not be correct if you have more professional Odoo experience than I do. Take it as a war story, not a best practice guide.
TL;DR
Odoo 18’s core hr_attendance module works fine. The overtime computation, the unique constraint, the _update_overtime() logic — all correct. The problem? A third-party custom module that overrides create() and write() on hr.attendance without understanding the side effects. It triggers _update_overtime() multiple times for the same day, causing a duplicate key violation. Your attendance record gets rolled back. The employee’s scan vanishes.
You can’t fix someone else’s module. You can’t modify core. So you fix it in your own module. Welcome to the Odoo ecosystem.
The Setup
I built MeID oConnect — an Odoo module that receives biometric attendance scans via a JSON-RPC endpoint:
One create() for check-in, one write() for check-out. Nothing fancy.
It worked in dev. Then production happened.
{
"odooError": {
"message": "duplicate key value violates unique constraint \"hr_attendance_overtime_unique_employee_per_day\"\nDETAIL: Key (employee_id, date)=(336, 2026-04-09) already exists."
}
}
How Core Works (Correctly)
Odoo’s hr_attendance has a clean design:
A unique index enforces one overtime per employee per day:
CREATE UNIQUE INDEX hr_attendance_overtime_unique_employee_per_day
ON hr_attendance_overtime (employee_id, date)
WHERE adjustment is false
This works perfectly when you call write() once and let core do its thing.
How a Custom Module Breaks It
On this project, a third-party custom module (abxxx_hr_attendance) overrides create() and write() on hr.attendance. Here’s what its create() does (simplified):
class HrAttendance(models.Model):
_inherit = 'hr.attendance'
def create(self, vals):
# Only bypass for 'api' or 'manual' mode
if vals.get('in_mode') in ['api', 'manual']:
return super().create(vals)
# Otherwise, do custom logic:
today_attendance = self.search([...]) # find today's record
if today_attendance:
today_attendance.write({ # ← write #1
'check_out': fields.Datetime.now(),
...
})
return today_attendance
else:
vals['check_in'] = fields.Datetime.now() # ← overwrites your timestamp!
return super().create(vals) # ← triggers write #2 via core
And its write():
def write(self, vals):
# Only bypass for 'api', 'manual', 'auto_check_out' mode
if vals.get('out_mode') in ['api', 'manual', 'auto_check_out']:
return super().write(vals)
# Otherwise: IP check, device check, custom logic...
self._check_ip_restriction(employee_id) # ← can raise
self._check_device_restriction(...) # ← can raise
...
Two problems at once:
Problem 1: Double _update_overtime()
My controller calls Attendance.create({..., 'in_mode': 'meid'}). Since 'meid' is not in the custom module’s bypass list ['api', 'manual'], it enters the custom logic:
Same employee, same day, two overtime calculations. The second one tries to INSERT a record that already exists. Boom.
Problem 2: Timestamp Overwrite
The custom module replaces my check_in value with fields.Datetime.now():
vals['check_in'] = fields.Datetime.now()
My MeID device sent 2026-04-09T05:55:00Z (the actual scan time). The custom module throws it away and uses the server’s current time. The attendance record is technically “correct” but the timestamp is wrong.
What I Tried
Attempt 1: Catch the exception
except (UserError, ValidationError) as exc:
env.cr.rollback()
Failed. IntegrityError from psycopg2 isn’t always wrapped as ValidationError. It can bypass the handler or get raised during Odoo’s post-response commit.
Attempt 2: Savepoint + retry
try:
with env.cr.savepoint():
today_att.write({'check_out': dt_utc})
except IntegrityError:
_clear_overtime(...)
today_att.write({'check_out': dt_utc})
Failed. Savepoint rollback also undoes the attendance write. ORM cache invalidation after rollback is unreliable. Deployed, still broke.
Attempt 3: Pre-emptively delete overtime records
env['hr.attendance.overtime'].sudo().search([...]).unlink()
Failed. The custom module’s double write() triggers _update_overtime() twice in the same transaction. Even with a clean table, the second call creates a duplicate.
Attempt 4: Change in_mode to 'api'
Attendance.create({..., 'in_mode': 'api', 'out_mode': 'api'})
Partially worked. This bypasses the custom module’s override — no IP check, no timestamp overwrite, no double write(). But Odoo core’s _update_overtime() can still occasionally fail when there’s an existing overtime record from an earlier operation (auto-close of stale records, cron jobs, etc.).
What Actually Worked
The in_mode='api' change was necessary but not sufficient. The real fix: override hr.attendance.overtime.create() in my own module to make the batch INSERT idempotent.
class HrAttendanceOvertime(models.Model):
_inherit = 'hr.attendance.overtime'
@api.model_create_multi
def create(self, vals_list):
# 1. Deduplicate by (employee_id, date)
seen = {}
deduped = []
for vals in vals_list:
if vals.get('adjustment'):
deduped.append(vals)
continue
key = (vals.get('employee_id'), str(vals.get('date')))
if key in seen:
seen[key].update(vals)
else:
seen[key] = vals
deduped.append(vals)
# 2. Upsert: update existing instead of creating duplicates
to_create = []
updated = self.browse()
for vals in deduped:
if vals.get('adjustment'):
to_create.append(vals)
continue
existing = self.sudo().search([
('employee_id', '=', vals.get('employee_id')),
('date', '=', vals.get('date')),
('adjustment', '=', False),
], limit=1)
if existing:
existing.sudo().write({
'duration': vals.get('duration', 0),
'duration_real': vals.get('duration_real', 0),
})
updated |= existing
else:
to_create.append(vals)
created = super().create(to_create)
return created | updated
50 lines of defense against someone else’s create() override.
The Real Problem
Odoo core is fine. _update_overtime() works correctly when called once per operation. The unique constraint is correct. The ORM is doing its job.
The problem is the ecosystem. In a typical Odoo deployment, you have:
- Core modules (maintained by Odoo SA)
- Third-party custom modules (maintained by whoever the client hired)
- Your modules (maintained by you)
They all share the same ORM inheritance chain. One bad override in any module affects every module downstream. There’s no isolation. No interface contract. No way to say “this create() should not be overridden by other modules.”
The custom module that caused this:
- Overrides
create()andwrite()to add IP and device restrictions — reasonable feature - But adds a bypass list that only includes
['api', 'manual']— anyone using a different mode gets pulled into its custom logic - Calls
write()inside its owncreate()— triggers_update_overtime()twice - Replaces the caller’s
check_intimestamp withDatetime.now()— destroys external data
None of these are “bugs” in isolation. The module works perfectly for its intended use case: employees checking in via Odoo’s web interface. But it breaks every other attendance integration that exists or will ever exist.
Lessons
-
When debugging Odoo, check the full inheritance chain. The error says
hr.attendance, but the bug might be three overrides deep. Runself.env['hr.attendance']._inheritor grep for_inherit = 'hr.attendance'across all installed modules. -
Use
in_mode='api'for server-to-server integrations. It’s the conventional signal for “I know what I’m doing, don’t touch my data.” Most well-written custom modules respect it. -
Make your overrides defensive. If you override
create(), don’t assume you know every caller. Don’t replace values the caller explicitly set. Don’t callwrite()insidecreate()unless you understand what side effects that triggers. -
The overtime unique constraint is a canary. If you hit
hr_attendance_overtime_unique_employee_per_day, something is calling_update_overtime()more than once per operation. Find who and why. -
Override at the right level. I couldn’t fix the custom module. I couldn’t patch core. But I could override
hr.attendance.overtime.create()to make the INSERT idempotent. Pick the narrowest fix that solves the problem without creating new ones.
This bug cost me a day. The fix is 50 lines. The root cause is a module I didn’t write, can’t modify, and didn’t know existed until production broke. That’s Odoo for you.