Post

When a Bad Custom Module Breaks Odoo's Attendance — And You Have to Fix It in Yours

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.

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.

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.


I built MeID oConnect — an Odoo module that receives biometric attendance scans via a JSON-RPC endpoint:

flowchart LR A[MeID Device] --> B[Backend] B -->|POST /did-attendance/scan| C[Odoo hr.attendance]

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."
  }
}

Odoo’s hr_attendance has a clean design:

flowchart TD A["write(check_out)"] --> B["super().write()"] B --> C["_update_overtime()"] C --> D{"existing overtime?"} D -->|yes| E["update duration"] D -->|no| F["create overtime record"] F --> G["unique index: 1 per employee/day"]

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.

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:

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:

flowchart TD A["MeID: Attendance.create({in_mode: 'meid'})"] --> B{"abxxx: in_mode in\n['api','manual']?"} B -->|"no ('meid')"|C["abxxx: today_att.write({check_out})"] C --> D["core write() → _update_overtime() #1"] D --> E["INSERT overtime ✅"] C --> F["return (skips super().create)"] A -.->|"what should happen"| G["super().create()"] G --> H["core create() → _update_overtime() #2"] H --> I["INSERT overtime ❌ duplicate!"]

Same employee, same day, two overtime calculations. The second one tries to INSERT a record that already exists. Boom.

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.

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.

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.

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.

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.).

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
flowchart TD A["_update_overtime() calls\novertime.create(vals_list)"] --> B["Step 1: Dedup vals_list\nby (employee_id, date)"] B --> C["Step 2: For each entry,\ncheck DB for existing record"] C --> D{exists?} D -->|yes| E["write(duration) → updated"] D -->|no| F["keep in to_create"] E --> G["return created | updated"] F --> H["super().create(to_create)"] --> G

50 lines of defense against someone else’s create() override.

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() and write() 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 own create() — triggers _update_overtime() twice
  • Replaces the caller’s check_in timestamp with Datetime.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.

  1. When debugging Odoo, check the full inheritance chain. The error says hr.attendance, but the bug might be three overrides deep. Run self.env['hr.attendance']._inherit or grep for _inherit = 'hr.attendance' across all installed modules.

  2. 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.

  3. 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 call write() inside create() unless you understand what side effects that triggers.

  4. 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.

  5. 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.