EH HR Policy Engine
HR business rules as data, evaluated by a safe, side-effect-free DSL.
Why this module
EH HR Policy Engine
Rules become data
Caps, multipliers, eligibility tests and grace periods live in policy records an officer can edit, not in constants buried in feature models that need a developer to change.
A DSL that cannot escape
The evaluator supports literals, context reads, comparisons, boolean logic, arithmetic and a conditional. It cannot import, cannot call Python builtins, and never touches the ORM. Unknown nodes are rejected outright.
Right rule, right scope
Each policy is scoped to a company, optionally a department and an employee tag, with valid-from and valid-to dates. Resolution prefers the most specific match, then falls back to the company default.
Day in the life
One rule, changed without a release
Finance decides the weekend overtime multiplier for the Riyadh branch should move from 1.5 to 2.0 next month. An HR officer opens the matching policy record, edits the JSON body, and sets a valid-from date. A feature module that asks the policy engine to evaluate attendance.overtime.multiplier now reads the new value for that company on that date, while every other company keeps its own rule. No code change, no deployment, and the edit is written to the platform audit log with a correlation id.
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.
When several active policies share a code, the engine returns the one matching the employee's department first, then a policy with no department or tag, then the most recent company-level fallback, so a specific override always wins over a default.
resolve() filters on valid_from and valid_to against the supplied date, treating an empty bound as open, so a future-dated rate or a retired rule never resolves outside its window.
A database unique constraint on (code, company, department, employee_tag, active) prevents two active policies from colliding on the same scope, so resolution is never ambiguous. The constraint is declared for both the modern and legacy Odoo paths to hold from 16 to 19.
A global record rule scopes policies to the user's allowed companies, so an officer in one company cannot read or edit another company's rule set, and resolution runs against the employee's own company.
The evaluator walks the JSON node by node. Anything outside the allowed vocabulary (an unknown key, a malformed operator argument, an unsupported literal type) raises a UserError instead of executing, so a bad policy fails loudly rather than silently.
A policy body seeded from XML text can land double-encoded as a JSON string. config() detects this and parses it back to a dict, so configuration-style policies still resolve cleanly when loaded from data files.
Because the model inherits the platform mixin, each record carries a correlation id from creation and policy events cross-emit to the append-only, hash-chained audit log in the core, so who changed a rule and when is recoverable.
What is inside
Built to do the job, end to end.
- One model: eh.hr.policy. A scoped, dated policy record carrying a stable code, a name, a JSON body, a version counter and active flag, edited through a built-in form with a JSON code editor for the body.
- Policy engine service. A registered service, eh.hr.policy.engine, exposing resolve() to find the active policy for a scope and date, evaluate() to run the DSL body against a context dict, and config() to read a policy body as a plain configuration dict.
- The restricted DSL. Literals, context reads via a dotted path, comparisons (eq, ne, gt, ge, lt, le), boolean and/or/not, arithmetic add/sub/mul/div, and an if conditional. No imports, no builtins, no ORM access, no state mutation.
- Security and scoping. Access rules for HR admin (full), HR officer (read, write, create) and self-service employees (read only), plus a per-company record rule so rule sets stay isolated between companies.
- Built on the platform core. Inherits eh.hr.platform.mixin for a correlation id and audit-logged platform events, and depends only on eh_hr_core. No standard Odoo models are modified.
Honest about the edges
What this does not do, so nothing surprises you.
- This is an engine for other modules to consume. On its own it stores and evaluates policy records, it does not by itself enforce leave caps or compute overtime. Sibling feature modules call the engine to read those values.
- The version field is a stored revision marker, not an automatic change-history. It does not auto-increment on each write and the module does not keep prior bodies as separate revisions.
- Resolution runs a live search on each call. There is no result caching layer in this module, so very high-frequency evaluation should be batched by the calling module.
- The DSL is intentionally minimal. It has no loops, no string functions, no date arithmetic and no user-defined functions, by design, to keep evaluation safe and predictable.
- Policy editing is exposed through a JSON code editor on the form, not a guided visual rule builder. Authors write the policy body as a JSON document.
- Multi-company isolation relies on standard Odoo company security. It scopes per company and resolves against the employee's company, it is not a cross-company consolidation tool.
Odoo HR rules engine, Odoo policy engine, no-code business rules Odoo, Odoo leave cap configuration, overtime multiplier rules Odoo, eligibility rules HR, per company HR policy, department scoped policy, JSON rule DSL Odoo, safe expression evaluator Odoo, configurable HR thresholds, Odoo 19 HR platform, Odoo Community HR rules, versioned HR policy, grace period configuration Odoo
Need this fitted to the way you work?
ERP Heritage delivers end to end Odoo work: Odoo Implementation, Customization and Development, Integration, Migration, Consultation, Support and Training. We help teams put this module into production, shape it to their process, and keep it running.
We work with businesses across Australia (Melbourne, Sydney, Brisbane, Perth, Adelaide, Canberra) and the Middle East (Dubai, Abu Dhabi, Riyadh, Jeddah, Doha, Kuwait City, Muscat). Start a conversation at erpheritage.com.au or email info@erpheritage.com.au.
Languages
Available in 19 languages
The interface ships translated out of the box. Switch language in Odoo and the fields, menus, and messages follow.
Please log in to comment on this module