EH HR Approval Engine
Reusable N-step approval chains, defined as data, enforced in code.
Why this module
EH HR Approval Engine
One engine, every workflow
Any model that needs sign-off inherits the approvable mixin and declares a chain code. The engine handles routing, decisions, advancement and closure, so each workflow module stops re-implementing its own manager-approval state machine.
Chains are data, not Python
An administrator defines a chain, its ordered steps, the approver groups or a dynamic rule per step, and the escalation window. Serial, parallel any-of, parallel all-of and conditional policy strategies are a field on the chain, not a code change.
Yours, and self-hosted
LGPL-3 source on your own server. No per-user fee, no usage cap, no data leaving your database. Runs on Odoo 16, 17, 18 and 19 Community from one authored source.
Day in the life
A leave request walks the chain without anyone touching code.
An employee submits a record for approval. The engine opens a request against the configured chain and captures who really submitted it, before any privileged escalation. Step one routes to the employee's line manager, resolved dynamically at runtime, not hard-wired. The manager approves and a serial chain advances to step two, an officer group. Because the submitter happens to hold that officer group, the engine refuses to let them approve their own submission, the self-approval guard holds even though they are otherwise authorized. Step two sits idle past its escalation window, so the hourly cron notifies the step's approvers and the HR admins once, flags the request so it does not nag every run, and resets that flag the moment the step advances. The second officer approves, the chain closes as approved, fires the follow-up transition on the source record by convention, and emits a platform event. Every decision, with its author and comment, is on the request's tracked message thread, and a company-scoped record rule keeps one company's requests invisible to another's officers.
Edge cases
The cases most modules quietly ignore.
In the shipped code today, each one a place where a cheaper module silently does the wrong thing.
A request can never be approved by a user it concerns. The subject employee, that employee's user, the record's own user, and the real submitter are all blocked, even when they hold an approver group. The submitter is captured before the engine's privileged call so it is the actual person, not the superuser.
The interactive decide() path checks authorization, and a model constraint re-checks it on every create path, including a direct ORM or privileged write. A decision row by an unauthorized or self-approving user is rejected wherever it originates, and the engine also filters stray rows when it counts votes.
The parallel all-of strategy advances only when the approvers who voted collectively cover every required approver group on the step. A partial set of approvals leaves the step pending rather than closing it early.
A conditional chain delegates the advance decision to a named policy DSL evaluated with the approval count and subject employee. An unconfigured conditional chain, or a policy that raises, never auto-advances. The safe default is to stay pending.
An overdue step is escalated exactly once. The request flags itself escalated so the hourly cron does not re-notify on every run, and the flag resets when the request advances to a new step. A step with escalation_hours set to zero never escalates.
Global record rules scope chains, steps, requests and decisions to the user's allowed companies, so an officer in one company cannot read another company's approval data. The request's company is sourced from the chain, the authoritative owner.
A self-service employee sees only requests they raised or are the subject of. A team manager sees only requests about their direct reports plus their own. Officers and admins keep the company-wide view, the rules OR together so the wider access wins for them.
The request is a mail.thread, so state changes and decisions are tracked with author and timestamp. Decision rows carry the deciding user, step, decision and comment, and officers hold no unlink right on them, so the recorded outcome stays put.
What is inside
Built to do the job, end to end.
- Approvable mixin. eh.hr.approvable.mixin makes a record approvable: set a chain code, gain action_submit_for_approval(), and the engine takes over. The real submitter is captured before the privileged call so authorization can identify them later.
- Chain and step definitions. eh.hr.approval.chain holds the code, company, strategy and optional policy code. eh.hr.approval.step holds the ordered sequence, static approver groups, a dynamic approver rule (line manager, department manager or any HR officer) and an escalation window in hours.
- Request, decisions and engine. eh.hr.approval.request is one per attempted transition, glueing a record to a chain and its decisions. eh.hr.approval.decision records each vote. The engine service opens requests, counts authorized non-subject votes, advances or closes, and fires the follow-up transition on close.
- Escalation cron. An hourly ir.cron escalates pending requests whose current step is overdue, notifying the step's approvers and HR admins through chatter and, when present, the notification engine with a dedupe key. Built for Odoo 19 without the dropped numbercall and doall fields.
- Security model. Per-company global record rules on all four models, plus role-scoped read rules for self-service employees, team managers and officers. Access rights give officers read and limited write, admins full control, and withhold decision unlink from officers.
Honest about the edges
What this does not do, so nothing surprises you.
- This is a platform engine, not a ready-to-use end-user app. It ships the approval models, the resolving service, an escalation cron and the security model. The visible approval menus exist for administrators and officers; consuming workflow modules are what put it in front of employees.
- Out-of-office delegation, in the sense of a configured substitute approver who can act for an absent one, is not implemented. Approver resolution is by static group plus one dynamic rule (line manager, department manager or any HR officer) per step. Treat references to delegation as the submitter-versus-approver separation, not a stand-in feature.
- The conditional strategy depends on the policy engine. Without a configured policy code, a conditional chain never auto-advances and stays pending by design.
- Escalation notifies approvers and HR admins and flags the request; it does not auto-approve, auto-reassign, or skip an overdue step. The chain still waits for a real decision.
- Approver group resolution uses Odoo groups and the platform HR groups (officer, admin, manager, employee self) provided by eh_hr_core. It does not invent its own permission scheme.
- Requires eh_hr_core and eh_hr_engine_workflow. It is part of the EH HR Platform and is meant to run alongside it, not as a standalone approvals product.
Odoo HR approval workflow, multi-step approval chains, Odoo approval engine, manager approval routing, serial and parallel approvals, conditional policy approvals, approver groups Odoo, self-approval prevention, approval escalation cron, multi-company approval rules, Odoo 19 Community HR, HR sign-off workflow, approvable record mixin, dynamic approver line manager, audited approval trail
Please log in to comment on this module