19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -0,0 +1 @@
from . import hr_leave_attendance_report

View file

@ -0,0 +1,241 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.tools.sql import SQL, drop_view_if_exists
class HrLeaveAttendanceReport(models.Model):
_name = "hr.leave.attendance.report"
_description = "Attendance and Leave Analysis Report"
_auto = False
def _compute_display_name(self):
for rec in self:
rec.display_name = f"{rec.employee_id.display_name}, {rec.date}"
date = fields.Date("Date")
employee_id = fields.Many2one("hr.employee", string="Employee")
active = fields.Boolean(related="employee_id.active")
department_id = fields.Many2one(related="employee_id.department_id", string="Department")
job_id = fields.Many2one(related="employee_id.job_id", string="Job Position")
schedule_id = fields.Many2one("resource.calendar", string="Working Schedule")
expected_hours = fields.Float("Expected Hours")
worked_hours = fields.Float("Worked Hours")
leave_hours = fields.Float("Approved Time Off")
difference_hours = fields.Float("Difference", help="Worked Hours - Expected Hours + Approved Time Off")
leave_type_names = fields.Char("Time Off Types", compute="_compute_leave_attendance_fields")
leave_ids = fields.Many2many("hr.leave", string="Time Offs", compute="_compute_leave_attendance_fields")
attendance_ids = fields.Many2many("hr.attendance", string="Attendances", compute="_compute_leave_attendance_fields")
@api.depends('employee_id', 'date')
def _compute_leave_attendance_fields(self):
today = fields.Date.today()
min_date = today - relativedelta(years=1)
max_date = today - relativedelta(days=1)
leaves_by_employees = dict(self.env['hr.leave']._read_group(
domain=[
('employee_id', 'in', self.employee_id.ids),
('state', '=', 'validate'),
('date_from', '<=', max_date),
('date_to', '>=', min_date),
],
groupby=['employee_id'],
aggregates=['id:recordset'],
))
attendances_by_employees = dict(self.env['hr.attendance']._read_group(
domain=[
('employee_id', 'in', self.employee_id.ids),
('check_in', '>=', min_date),
('check_in', '<=', max_date),
],
groupby=['employee_id'],
aggregates=['id:recordset'],
))
for rec in self:
leaves = leaves_by_employees.get(rec.employee_id, self.env['hr.leave'])
rec_date_leaves = leaves.filtered(
lambda lv: self._timestamped(lv.date_from) <= rec.date <= self._timestamped(lv.date_to),
)
rec.leave_ids = rec_date_leaves.ids
leave_type_ids = rec_date_leaves.mapped('holiday_status_id')
rec.leave_type_names = ', '.join(leave_type_ids.mapped('name'))
attendances = attendances_by_employees.get(rec.employee_id, self.env['hr.attendance'])
rec.attendance_ids = attendances.filtered(
lambda att: self._timestamped(att.check_in) == rec.date,
).ids
def _timestamped(self, date):
return fields.Datetime.context_timestamp(self, date).date()
def _select(self):
return """
SELECT row_number() OVER (ORDER BY gs.day DESC, emp.id) AS id,
gs.day::date AS date,
emp.id AS employee_id,
rc.id AS schedule_id,
ROUND(COALESCE(att.worked_hours, 0.0)::numeric, 2) AS worked_hours,
ROUND(COALESCE(rc.hours_per_day, 0.0)::numeric, 2) AS expected_hours,
ROUND(COALESCE(dlh.leave_hours, 0.0)::numeric, 2) AS leave_hours,
(
ROUND(COALESCE(att.worked_hours, 0.0)::numeric, 2)
- ROUND(COALESCE(rc.hours_per_day, 0.0)::numeric, 2)
+ ROUND(COALESCE(dlh.leave_hours, 0.0)::numeric, 2)
) AS difference_hours
"""
def _from(self):
return """
FROM hr_employee AS emp
CROSS JOIN LATERAL generate_series(
(date_trunc('month', CURRENT_DATE) - INTERVAL '1 year')::date,
(CURRENT_DATE - 1)::date,
INTERVAL '1 day'
) AS gs(day)
LEFT JOIN LATERAL (
SELECT resource_calendar_id
FROM hr_version AS v
WHERE v.employee_id = emp.id
AND v.contract_date_start IS NOT NULL
AND v.contract_date_start <= gs.day
AND (v.contract_date_end >= gs.day OR v.contract_date_end IS NULL)
AND v.date_version <= gs.day
ORDER BY v.date_version DESC
LIMIT 1
) AS ver
ON TRUE
"""
def _join_attendance(self):
return """
LEFT JOIN (
SELECT employee_id,
(check_in AT TIME ZONE 'UTC')::date AS check_date,
SUM(worked_hours) AS worked_hours
FROM hr_attendance
GROUP BY employee_id, check_date
) AS att
ON att.employee_id = emp.id
AND att.check_date = gs.day
"""
def _join_calendar(self):
return """
JOIN resource_calendar AS rc
ON ver.resource_calendar_id = rc.id
"""
def _join_calendar_leaves(self):
return """
LEFT JOIN resource_calendar_leaves AS rcl
ON (
(rc.id = rcl.calendar_id OR rcl.calendar_id IS NULL)
AND rcl.resource_id IS NULL
AND rcl.company_id = emp.company_id
AND gs.day
BETWEEN (rcl.date_from AT TIME ZONE 'UTC')::date
AND (rcl.date_to AT TIME ZONE 'UTC')::date
)
"""
def _join_resource_calendar_attendance(self):
return """
LEFT JOIN (
SELECT DISTINCT ON (calendar_id, dayofweek) *
FROM resource_calendar_attendance
ORDER BY calendar_id, dayofweek
) AS rca
ON rc.id = rca.calendar_id
AND CAST(rca.dayofweek AS INTEGER) = (
CASE
WHEN EXTRACT(DOW FROM gs.day) = 0 THEN 6
ELSE EXTRACT(DOW FROM gs.day) - 1
END
) -- to map days between Odoo (Monday = 0, Tuesday = 1, ...) and Postgres (Sunday = 0, Monday = 1, ...)
"""
def _join_daily_leave_hours(self):
""" Generates a SQL join clause to calculate the total `leave_hours` taken for a specific day:
- Sums up `number_of_hours` for all validated time offs within the day.
- Excludes non-working days based on the employee's resource calendar and calendar leaves.
- Handles leaves spanning multiple days, ensuring only working days are counted.
- Converts all date fields to UTC to maintain consistency with Odoo's date storage.
"""
return """
LEFT JOIN LATERAL (
SELECT SUM(lv.number_of_hours / NULLIF(wd.working_days,0)) AS leave_hours
FROM hr_leave AS lv
JOIN LATERAL (
SELECT COUNT(*) AS working_days
FROM generate_series(
(lv.date_from AT TIME ZONE 'UTC')::date,
(lv.date_to AT TIME ZONE 'UTC')::date,
INTERVAL '1 day'
)
AS d(day)
INNER JOIN (
SELECT DISTINCT ON (calendar_id, dayofweek) *
FROM resource_calendar_attendance
ORDER BY calendar_id, dayofweek
) AS rca2
ON rc.id = rca2.calendar_id
AND CAST(rca2.dayofweek AS INTEGER) = (
CASE
WHEN EXTRACT(DOW FROM d.day) = 0 THEN 6
ELSE EXTRACT(DOW FROM d.day) - 1
END
)
LEFT JOIN resource_calendar_leaves rcl2
ON (
(rc.id = rcl2.calendar_id OR rcl2.calendar_id IS NULL)
AND rcl2.resource_id IS NULL
AND rcl2.company_id = emp.company_id
AND d.day
BETWEEN (rcl2.date_from AT TIME ZONE 'UTC')::date
AND (rcl2.date_to AT TIME ZONE 'UTC')::date
)
WHERE rcl2.id IS NULL
) AS wd ON TRUE
WHERE lv.employee_id = emp.id
AND gs.day
BETWEEN (lv.date_from AT TIME ZONE 'UTC')::date
AND (lv.date_to AT TIME ZONE 'UTC')::date
AND lv.state = 'validate'
) AS dlh
ON TRUE
"""
def _where(self):
return """
WHERE rca.id IS NOT NULL
AND rcl.id IS NULL
"""
def init(self):
drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(SQL("""
CREATE OR REPLACE VIEW %s AS (
%s -- select
%s -- from
%s -- join_attendance
%s -- join_calendar
%s -- join_calendar_leaves
%s -- join_resource_calendar_attendance
%s -- join_daily_leave_hours
%s -- where
)""",
SQL.identifier(self._table),
SQL(self._select()),
SQL(self._from()),
SQL(self._join_attendance()),
SQL(self._join_calendar()),
SQL(self._join_calendar_leaves()),
SQL(self._join_resource_calendar_attendance()),
SQL(self._join_daily_leave_hours()),
SQL(self._where()),
))