[MERGE] hr_payroll: Introduce hr.benefit model
Purpose ======= For payroll purpose, we need to manage attendances. For instance, someone is working only mondays, tuesdays and fridays, but works exceptionally an other day. It must be recorded. Specification =========== The new models are used to represent attendances, extra hours and leaves (as `hr.benefit`s) in a new window called "Benefits". The schedule of employees for the full month is visible in that view (calendar view). First the benefits needs to be generated (via a button) based on the calendar attendances and leaves. The manager can then check benefits, delete or add some. Only the benefit name and type can be edited (not start and end dates). Once benefits are correct, they can be validated. Validation will fail if they are some leaves to approve or if some benefits overlap on the same day. Benefits cannot be deleted or edited once validated. When benefits are all validated, a button appears to generate payslips. Additional development ================== Most of the time we don't what is the next action that should be done when creating payslips or payslip batches. - Improve the layout, the buttons of the payroll object. - Refactor a little the code to reuse existing chunks of code instead of reinventing the wheel in several methods. - Remove the hr.rule.input model. - Remove an useless report. See each individual commit for more information. TaskID: 1912944, 1903239 closes odoo/odoo#29065
This commit is contained in:
@@ -27,7 +27,12 @@
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/report_contributionregister_templates.xml',
|
||||
'views/report_payslip_templates.xml',
|
||||
'views/report_payslipdetails_templates.xml',
|
||||
'views/hr_benefit_views.xml',
|
||||
'views/hr_benefit_views.xml',
|
||||
'views/hr_leave_views.xml',
|
||||
'views/resource_views.xml',
|
||||
'views/hr_benefit_template.xml',
|
||||
'wizard/hr_benefit_employee_views.xml',
|
||||
],
|
||||
'demo': ['data/hr_payroll_demo.xml'],
|
||||
}
|
||||
|
||||
@@ -89,6 +89,20 @@
|
||||
<field name="digits">4</field>
|
||||
</record>
|
||||
|
||||
<!-- Benefits Type -->
|
||||
|
||||
<record id="benefit_type_attendance" model="hr.benefit.type">
|
||||
<field name="name">Attendance</field>
|
||||
<field name="color">0</field>
|
||||
<field name="code">WORK100</field>
|
||||
</record>
|
||||
|
||||
<record id="benefit_type_leave" model="hr.benefit.type">
|
||||
<field name="name">Generic Leave</field>
|
||||
<field name="code">LEAVE100</field>
|
||||
<field name="color">3</field>
|
||||
<field name="is_leave">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -82,29 +82,6 @@
|
||||
<field name="sequence" eval="16"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_salary_rule_sales_commission" model="hr.salary.rule">
|
||||
<field name="amount_select">code</field>
|
||||
<field name="code">SALE</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="name">Get 1% of sales</field>
|
||||
<field name="sequence" eval="17"/>
|
||||
<field name="amount_python_compute">result = ((inputs.SALEURO and inputs.SALEURO.amount) + (inputs.SALASIA and inputs.SALASIA.amount)) * 0.01</field>
|
||||
</record>
|
||||
|
||||
<!-- Rule Inputs -->
|
||||
|
||||
<record id="hr_rule_input_sale_a" model="hr.rule.input">
|
||||
<field name="code">SALEURO</field>
|
||||
<field name="name">Sales to Europe</field>
|
||||
<field name="input_id" ref="hr_salary_rule_sales_commission"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_sale_b" model="hr.rule.input">
|
||||
<field name="code">SALASIA</field>
|
||||
<field name="name">Sales to Asia</field>
|
||||
<field name="input_id" ref="hr_salary_rule_sales_commission"/>
|
||||
</record>
|
||||
|
||||
<!-- Salary Structure -->
|
||||
|
||||
<record id="structure_001" model="hr.payroll.structure">
|
||||
@@ -146,6 +123,7 @@
|
||||
<field name="employee_id" ref="hr_employee_payroll"/>
|
||||
<field name="notes">Default contract for marketing executives</field>
|
||||
<field eval="4000.0" name="wage"/>
|
||||
<field name="state">open</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_contract_gilles_gravie" model="hr.contract">
|
||||
@@ -159,4 +137,71 @@
|
||||
<field eval="5000.0" name="wage"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_contract_admin" model="hr.contract">
|
||||
<field name="name">Contract For Mitchell Admin</field>
|
||||
<field name="type_id" ref="hr_contract.hr_contract_type_emp"/>
|
||||
<field name="date_start" eval="time.strftime('%Y-%m')+'-1'"/>
|
||||
<field name="date_end" eval="time.strftime('%Y')+'-12-31'"/>
|
||||
<field name="struct_id" ref="hr_payroll.structure_002"/>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="notes">This is Mitchell Admin's contract</field>
|
||||
<field eval="5500.0" name="wage"/>
|
||||
<field name="state">open</field>
|
||||
</record>
|
||||
|
||||
<!-- Benefits -->
|
||||
<record id="benefit_type_home_working" model="hr.benefit.type">
|
||||
<field name="name">Home Working</field>
|
||||
<field name="code">WORK200</field>
|
||||
<field name="color">2</field>
|
||||
</record>
|
||||
|
||||
<record id="benefit_type_extra_hours" model="hr.benefit.type">
|
||||
<field name="name">Extra hours</field>
|
||||
<field name="code">WORK300</field>
|
||||
<field name="color">2</field>
|
||||
</record>
|
||||
|
||||
<record id="benefit_type_long_leave" model="hr.benefit.type">
|
||||
<field name="name">Long Term Leave</field>
|
||||
<field name="code">LEAVE200</field>
|
||||
<field name="is_leave">True</field>
|
||||
<field name="color">4</field>
|
||||
</record>
|
||||
|
||||
<record id="benefit_type_unpaid_leave" model="hr.benefit.type">
|
||||
<field name="name">Unpaid Leave</field>
|
||||
<field name="code">LEAVE300</field>
|
||||
<field name="is_leave">True</field>
|
||||
<field name="color">5</field>
|
||||
</record>
|
||||
|
||||
<record id="benefit_type_sick_leave" model="hr.benefit.type">
|
||||
<field name="name">Sick Leave</field>
|
||||
<field name="code">LEAVE400</field>
|
||||
<field name="is_leave">True</field>
|
||||
<field name="color">5</field>
|
||||
</record>
|
||||
|
||||
<!-- add benefit type to leave type -->
|
||||
<record id="hr_holidays.holiday_status_sl" model="hr.leave.type">
|
||||
<field name="benefit_type_id" ref="benefit_type_sick_leave"></field>
|
||||
</record>
|
||||
|
||||
<record id="hr_holidays.holiday_status_unpaid" model="hr.leave.type">
|
||||
<field name="benefit_type_id" ref="benefit_type_unpaid_leave"></field>
|
||||
</record>
|
||||
|
||||
<record id="hr_holidays.holiday_status_hw" model="hr.leave.type">
|
||||
<field name="benefit_type_id" ref="benefit_type_home_working"></field>
|
||||
</record>
|
||||
|
||||
<record id="hr_holidays.holiday_status_cl" model="hr.leave.type">
|
||||
<field name="benefit_type_id" ref="benefit_type_leave"></field>
|
||||
</record>
|
||||
|
||||
<record id="hr_holidays.holiday_status_comp" model="hr.leave.type">
|
||||
<field name="benefit_type_id" ref="benefit_type_leave"></field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -7,3 +7,6 @@ from . import hr_employee
|
||||
from . import res_config_settings
|
||||
from . import hr_salary_rule
|
||||
from . import hr_payslip
|
||||
from . import hr_benefit
|
||||
from . import hr_leave
|
||||
from . import resource
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import pytz
|
||||
import itertools
|
||||
from psycopg2 import IntegrityError
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import api, fields, models, exceptions, _
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.addons.resource.models.resource import Intervals
|
||||
|
||||
class HrBenefit(models.Model):
|
||||
_name = 'hr.benefit'
|
||||
_description = 'hr.benefit'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
employee_id = fields.Many2one('hr.employee', required=True,
|
||||
domain=lambda self: [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)])
|
||||
date_start = fields.Datetime(required=True, string='Start')
|
||||
date_stop = fields.Datetime(string='End')
|
||||
duration = fields.Float(compute='_compute_duration', inverse='_inverse_duration', store=True, string="Hours")
|
||||
benefit_type_id = fields.Many2one('hr.benefit.type')
|
||||
color = fields.Integer(related='benefit_type_id.color', readonly=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('validated', 'Validated'),
|
||||
('cancelled', 'Cancelled')
|
||||
], default='draft')
|
||||
display_warning = fields.Boolean(string="Error")
|
||||
leave_id = fields.Many2one('hr.leave', string='Leave')
|
||||
|
||||
_sql_constraints = [
|
||||
('_unique', 'unique (employee_id, date_start, date_stop, benefit_type_id)', "Benefit already exists for this attendence"),
|
||||
]
|
||||
|
||||
@api.constrains('date_stop', 'duration')
|
||||
def _check_validity_benefit_ends(self):
|
||||
""" verifies if benefit has an end. """
|
||||
for benefit in self:
|
||||
if not (benefit.date_stop or benefit.duration):
|
||||
raise exceptions.ValidationError(_('Benefit must end. Please define an end date or a duration.'))
|
||||
|
||||
@api.constrains('date_start', 'date_stop')
|
||||
def _check_validity_start_before_end(self):
|
||||
""" verifies if benefit has an end. """
|
||||
for benefit in self:
|
||||
if benefit.date_stop < benefit.date_start:
|
||||
raise exceptions.ValidationError(_('Starting time should be before end time.'))
|
||||
|
||||
|
||||
@api.onchange('duration')
|
||||
def _onchange_duration(self):
|
||||
self._inverse_duration()
|
||||
|
||||
@api.depends('date_stop', 'date_start')
|
||||
def _compute_duration(self):
|
||||
for benefit in self:
|
||||
if benefit.date_start and benefit.date_stop:
|
||||
dt = benefit.date_stop - benefit.date_start
|
||||
benefit.duration = dt.days * 24 + dt.seconds / 3600 # Number of hours
|
||||
|
||||
def _inverse_duration(self):
|
||||
for benefit in self:
|
||||
if benefit.date_start and benefit.duration:
|
||||
benefit.date_stop = benefit.date_start + relativedelta(hours=benefit.duration)
|
||||
|
||||
def write(self, vals):
|
||||
if 'state' in vals:
|
||||
if vals['state'] == 'draft':
|
||||
vals['active'] = True
|
||||
if vals['state'] == 'cancelled':
|
||||
vals['active'] = False
|
||||
return super(HrBenefit, self).write(vals)
|
||||
|
||||
@api.multi
|
||||
def check_if_error(self):
|
||||
if not self:
|
||||
return False
|
||||
undefined_type = self.filtered(lambda b: not b.benefit_type_id)
|
||||
undefined_type.write({'display_warning': True})
|
||||
conflict = self._compute_schedule_conflicts()
|
||||
conflict_with_leaves = self.compute_conflicts_leaves_to_approve()
|
||||
return undefined_type or conflict or conflict_with_leaves
|
||||
|
||||
@api.multi
|
||||
def _compute_schedule_conflicts(self):
|
||||
conflict = False
|
||||
date_start_benefits = min(self.mapped('date_start'))
|
||||
date_stop_benefits = max(self.mapped('date_stop'))
|
||||
domain = [
|
||||
('date_start', '<', date_stop_benefits),
|
||||
('date_stop', '>', date_start_benefits),
|
||||
]
|
||||
|
||||
benefs = self.search(domain)
|
||||
benefits_by_employee = itertools.groupby(benefs, lambda b: b.employee_id)
|
||||
for employee, benefs in benefits_by_employee:
|
||||
intervals = Intervals(intervals=((b.date_start, b.date_stop, b) for b in benefs))
|
||||
for interval in intervals:
|
||||
if len(interval[2]) > 1:
|
||||
interval[2].write({'display_warning': True})
|
||||
conflict = True
|
||||
return conflict
|
||||
|
||||
@api.multi
|
||||
def compute_conflicts_leaves_to_approve(self):
|
||||
|
||||
if not self:
|
||||
return False
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
b.id AS benefit_id,
|
||||
l.id AS leave_id
|
||||
FROM hr_benefit b
|
||||
INNER JOIN hr_leave l ON b.employee_id = l.employee_id
|
||||
WHERE
|
||||
b.id IN %s AND
|
||||
l.date_from <= b.date_stop AND
|
||||
l.date_to >= b.date_start AND
|
||||
l.state IN ('confirm', 'validate1');
|
||||
"""
|
||||
self.env.cr.execute(query, [tuple(self.ids)])
|
||||
conflicts = self.env.cr.dictfetchall()
|
||||
for res in conflicts:
|
||||
self.browse(res.get('benefit_id')).write({
|
||||
'display_warning': True,
|
||||
'leave_id': res.get('leave_id')
|
||||
})
|
||||
return bool(conflicts)
|
||||
|
||||
def safe_duplicate_create(self, vals):
|
||||
"""
|
||||
Create benefit but silently abord if it already exists in the database (according to the _unique constraints).
|
||||
@return: new record if it didn't exist, empty recordset otherwise.
|
||||
"""
|
||||
try:
|
||||
with mute_logger('odoo.sql_db'), self.env.cr.savepoint():
|
||||
# Any database call will be run inside a postgresql savepoint which is
|
||||
# rolledback if an exception occurs allowing to make subsequent calls in the transaction.
|
||||
# (Otherwise InternalError is raised by any subsequent database operations)
|
||||
return self.create(vals)
|
||||
except IntegrityError as err:
|
||||
if 'hr_benefit__unique' not in err.pgerror:
|
||||
raise err
|
||||
return self.env['hr.benefit']
|
||||
|
||||
def action_leave(self):
|
||||
leave = self.leave_id
|
||||
return {
|
||||
'type':'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_id': leave.id,
|
||||
'res_model': 'hr.leave',
|
||||
'views': [[False, 'form']],
|
||||
}
|
||||
|
||||
def split_by_day(self):
|
||||
"""
|
||||
Split the benefit by days and unlink the original benefit.
|
||||
@return recordset
|
||||
"""
|
||||
def _split_range_by_day(start, end):
|
||||
days = []
|
||||
current_start = start
|
||||
current_end = start.replace(hour=23, minute=59, second=59)
|
||||
while current_end < end:
|
||||
days.append((current_start, current_end))
|
||||
current_start = current_end + relativedelta(seconds=1)
|
||||
current_end = current_end + relativedelta(days=1)
|
||||
|
||||
days.append((current_start, end))
|
||||
|
||||
# filter to avoid dummy intervals starting and ending at the same time
|
||||
return [(start, end) for start, end in days if start != end]
|
||||
|
||||
new_benefits = self.env['hr.benefit']
|
||||
for benefit in self:
|
||||
if benefit.date_start.date() == benefit.date_stop.date():
|
||||
new_benefits |= benefit
|
||||
else:
|
||||
tz = pytz.timezone(benefit.employee_id.tz)
|
||||
benefit_start, benefit_stop = tz.localize(benefit.date_start), tz.localize(benefit.date_stop)
|
||||
values = {
|
||||
'name': benefit.name,
|
||||
'employee_id': benefit.employee_id.id,
|
||||
'benefit_type_id': benefit.benefit_type_id.id,
|
||||
}
|
||||
benefit_state = benefit.state
|
||||
benefit.unlink()
|
||||
for start, stop in _split_range_by_day(benefit_start, benefit_stop):
|
||||
values['date_start'] = start.astimezone(pytz.utc)
|
||||
values['date_stop'] = stop.astimezone(pytz.utc)
|
||||
new_benefit = self.create(values)
|
||||
# Write the state after the creation due to the ir.rule on benefit state
|
||||
# LUL TODO: Write a test for this use case
|
||||
new_benefit.state = benefit_state
|
||||
new_benefits |= new_benefit
|
||||
|
||||
return new_benefits
|
||||
|
||||
@api.multi
|
||||
def _duplicate_to_calendar(self):
|
||||
"""
|
||||
Duplicate data to keep the complexity in benefit and not mess up payroll, etc.
|
||||
"""
|
||||
attendance_benefits = self.filtered(lambda b:
|
||||
not b.benefit_type_id.is_leave and
|
||||
# Normal benefit are global to all employees -> avoid duplicating it
|
||||
not b.benefit_type_id == self.env.ref('hr_payroll.benefit_type_attendance'))
|
||||
leave_benefits = self.filtered(lambda b: b.benefit_type_id.is_leave)
|
||||
|
||||
for benefit in attendance_benefits:
|
||||
benefit = benefit.split_by_day()
|
||||
benefit._duplicate_to_calendar_attendance()
|
||||
leave_benefits._duplicate_to_calendar_leave()
|
||||
|
||||
@api.multi
|
||||
def _duplicate_to_calendar_leave(self):
|
||||
|
||||
for benefit in self:
|
||||
if not benefit.leave_id:
|
||||
tz = pytz.timezone(benefit.employee_id.tz)
|
||||
self.env['resource.calendar.leaves'].create({
|
||||
'name': benefit.name,
|
||||
'date_from': benefit.date_start,
|
||||
'date_to': benefit.date_stop,
|
||||
'calendar_id': benefit.employee_id.resource_calendar_id.id,
|
||||
'resource_id': benefit.employee_id.resource_id.id,
|
||||
'benefit_type_id': benefit.benefit_type_id.id,
|
||||
})
|
||||
|
||||
@api.multi
|
||||
def _duplicate_to_calendar_attendance(self):
|
||||
mapped_data = {
|
||||
benefit: [
|
||||
pytz.utc.localize(benefit.date_start).astimezone(pytz.timezone(benefit.employee_id.tz)), # Start date
|
||||
pytz.utc.localize(benefit.date_stop).astimezone(pytz.timezone(benefit.employee_id.tz)) # End date
|
||||
] for benefit in self
|
||||
}
|
||||
|
||||
if any(data[0].date() != data[1].date() for data in mapped_data.values()):
|
||||
raise ValidationError(_("You can't validate a benefit that covers several days."))
|
||||
|
||||
for benefit in self:
|
||||
start, end = mapped_data.get(benefit)
|
||||
|
||||
self.env['resource.calendar.attendance'].create({
|
||||
'name': benefit.name,
|
||||
'dayofweek': str(start.weekday()),
|
||||
'date_from': start.date(),
|
||||
'date_to': end.date(),
|
||||
'hour_from':start.hour + start.minute/60,
|
||||
'hour_to': end.hour + end.minute/60,
|
||||
'calendar_id': benefit.employee_id.resource_calendar_id.id,
|
||||
'day_period': 'morning' if end.hour <= 12 else 'afternoon',
|
||||
'resource_id': benefit.employee_id.resource_id.id,
|
||||
'benefit_type_id': benefit.benefit_type_id.id,
|
||||
})
|
||||
|
||||
@api.model
|
||||
def action_validate(self, ids):
|
||||
benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')])
|
||||
benefits.write({'display_warning': False})
|
||||
if not benefits.check_if_error():
|
||||
benefits.write({'state': 'validated'})
|
||||
benefits._duplicate_to_calendar()
|
||||
return True
|
||||
return False
|
||||
|
||||
class HrBenefitType(models.Model):
|
||||
_name = 'hr.benefit.type'
|
||||
_description = 'hr.benefit.type'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char()
|
||||
color = fields.Integer(default=1) # Will be used with the new calendar/kanban view
|
||||
sequence = fields.Integer(default=25)
|
||||
active = fields.Boolean('Active', default=True,
|
||||
help="If the active field is set to false, it will allow you to hide the benefit type without removing it.")
|
||||
is_leave = fields.Boolean(default=False, string="Leave")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.resource.models.resource import Intervals
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
@@ -15,3 +18,47 @@ class HrEmployee(models.Model):
|
||||
def _compute_payslip_count(self):
|
||||
for employee in self:
|
||||
employee.payslip_count = len(employee.slip_ids)
|
||||
|
||||
|
||||
@api.multi
|
||||
def generate_benefit(self, date_start, date_stop):
|
||||
|
||||
date_start = date_start.replace(tzinfo=pytz.utc)
|
||||
date_stop = date_stop.replace(tzinfo=pytz.utc)
|
||||
|
||||
for employee in self:
|
||||
|
||||
# Approved leaves
|
||||
emp_leaves = employee.resource_calendar_id.leave_ids.filtered(
|
||||
lambda r:
|
||||
r.resource_id == employee.resource_id and
|
||||
r.date_from.replace(tzinfo=pytz.utc) <= date_stop and
|
||||
r.date_to.replace(tzinfo=pytz.utc) >= date_start
|
||||
)
|
||||
global_leaves = employee.resource_calendar_id.global_leave_ids
|
||||
for calendar_leave in emp_leaves | global_leaves:
|
||||
hr_leave = calendar_leave.holiday_id
|
||||
hr_leave.copy_to_benefits()
|
||||
|
||||
new_benefits = self.env['hr.benefit']
|
||||
for contract in employee._get_contracts(date_start, date_stop):
|
||||
|
||||
date_start_benefits = max(date_start, datetime.combine(contract.date_start, datetime.min.time()).replace(tzinfo=pytz.utc))
|
||||
date_stop_benefits = min(date_stop, datetime.combine(contract.date_end or datetime.max.date(), datetime.max.time()).replace(tzinfo=pytz.utc))
|
||||
|
||||
calendar = contract.resource_calendar_id
|
||||
resource = employee.resource_id
|
||||
attendances = calendar._work_intervals(date_start_benefits, date_stop_benefits, resource=resource)
|
||||
# Attendances
|
||||
for interval in attendances:
|
||||
benefit_type_id = interval[2].mapped('benefit_type_id')[:1] or self.env.ref('hr_payroll.benefit_type_attendance')
|
||||
new_benefits |= self.env['hr.benefit'].safe_duplicate_create({
|
||||
'name': "%s: %s" % (benefit_type_id.name, employee.name),
|
||||
'date_start': interval[0].astimezone(pytz.utc),
|
||||
'date_stop': interval[1].astimezone(pytz.utc),
|
||||
'benefit_type_id': benefit_type_id.id,
|
||||
'employee_id': employee.id,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
|
||||
new_benefits.compute_conflicts_leaves_to_approve()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
# 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.addons.resource.models.resource import Intervals
|
||||
|
||||
class HrLeaveType(models.Model):
|
||||
_inherit = 'hr.leave.type'
|
||||
|
||||
benefit_type_id = fields.Many2one('hr.benefit.type', string='Benefit Type')
|
||||
|
||||
|
||||
class HrLeave(models.Model):
|
||||
_inherit = 'hr.leave'
|
||||
|
||||
@api.multi
|
||||
def copy_to_benefits(self):
|
||||
for leave in self:
|
||||
benefit_type = leave.holiday_status_id.benefit_type_id
|
||||
self.env['hr.benefit'].safe_duplicate_create({
|
||||
'name': "%s%s" % (benefit_type.name + ": " if benefit_type else "", leave.employee_id.name),
|
||||
'date_start': leave.date_from,
|
||||
'date_stop': leave.date_to,
|
||||
'benefit_type_id': benefit_type.id,
|
||||
'employee_id': leave.employee_id.id,
|
||||
'leave_id': self.id,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
|
||||
@api.multi
|
||||
def _cancel_benefit_conflict(self):
|
||||
benefits = self.env['hr.benefit'].search([('leave_id', 'in', self.ids)])
|
||||
if benefits:
|
||||
self.copy_to_benefits()
|
||||
|
||||
# create new benefits where the leave does not cover the full benefit
|
||||
benefits_intervals = Intervals(intervals=[(b.date_start, b.date_stop, b) for b in benefits])
|
||||
leave_intervals = Intervals(intervals=[(l.date_from, l.date_to, l) for l in self])
|
||||
remaining_benefits = benefits_intervals - leave_intervals
|
||||
|
||||
for interval in remaining_benefits:
|
||||
benefit = interval[2]
|
||||
leave = benefit.leave_id
|
||||
benefit_type = benefit.benefit_type_id
|
||||
employee = benefit.employee_id
|
||||
|
||||
benefit_start = interval[0] + relativedelta(seconds=1) if leave.date_to == interval[0] else interval[0]
|
||||
benefit_stop = interval[1] - relativedelta(seconds=1) if leave.date_from == interval[1] else interval[1]
|
||||
|
||||
self.env['hr.benefit'].safe_duplicate_create({
|
||||
'name': "%s: %s" % (benefit_type.name, employee.name),
|
||||
'date_start': benefit_start,
|
||||
'date_stop': benefit_stop,
|
||||
'benefit_type_id': benefit_type.id,
|
||||
'employee_id': employee.id,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
benefits.unlink()
|
||||
|
||||
@api.multi
|
||||
def action_validate(self):
|
||||
super(HrLeave, self).action_validate()
|
||||
self._cancel_benefit_conflict()
|
||||
calendar_leaves = self.env['resource.calendar.leaves'].search([('holiday_id', 'in', self.ids)])
|
||||
calendar_leaves.write({'benefit_type_id': self.holiday_status_id.benefit_type_id.id})
|
||||
return True
|
||||
|
||||
@api.multi
|
||||
def action_refuse(self):
|
||||
super(HrLeave, self).action_refuse()
|
||||
benefits = self.env['hr.benefit'].search([('leave_id', 'in', self.ids)])
|
||||
benefits.write({'display_warning': False, 'leave_id': None})
|
||||
return True
|
||||
@@ -59,8 +59,6 @@ class HrPayslip(models.Model):
|
||||
note = fields.Text(string='Internal Note', readonly=True, states={'draft': [('readonly', False)]})
|
||||
contract_id = fields.Many2one('hr.contract', string='Contract', readonly=True,
|
||||
states={'draft': [('readonly', False)]})
|
||||
details_by_salary_rule_category = fields.One2many('hr.payslip.line',
|
||||
compute='_compute_details_by_salary_rule_category', string='Details by Salary Rule Category')
|
||||
credit_note = fields.Boolean(string='Credit Note', readonly=True,
|
||||
states={'draft': [('readonly', False)]},
|
||||
help="Indicates this payslip has a refund of another")
|
||||
@@ -68,11 +66,6 @@ class HrPayslip(models.Model):
|
||||
copy=False, states={'draft': [('readonly', False)]})
|
||||
payslip_count = fields.Integer(compute='_compute_payslip_count', string="Payslip Computation Details")
|
||||
|
||||
@api.multi
|
||||
def _compute_details_by_salary_rule_category(self):
|
||||
for payslip in self:
|
||||
payslip.details_by_salary_rule_category = payslip.mapped('line_ids').filtered(lambda line: line.category_id)
|
||||
|
||||
@api.multi
|
||||
def _compute_payslip_count(self):
|
||||
for payslip in self:
|
||||
@@ -119,6 +112,19 @@ class HrPayslip(models.Model):
|
||||
'context': {}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
res = super(HrPayslip, self).create(vals)
|
||||
if not res.payslip_run_id:
|
||||
self.env['hr.payslip.run'].create({
|
||||
'name': res.name,
|
||||
'date_start': res.date_from,
|
||||
'date_end': res.date_to,
|
||||
'slip_ids': [(4, res.id)],
|
||||
'state': 'close',
|
||||
})
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
if any(self.filtered(lambda payslip: payslip.state not in ('draft', 'cancel'))):
|
||||
@@ -136,14 +142,14 @@ class HrPayslip(models.Model):
|
||||
contracts = payslip.contract_id or \
|
||||
payslip.employee_id._get_contracts(payslip.date_from, payslip.date_to)
|
||||
lines = [(0, 0, line) for line in payslip._get_payslip_lines(contracts)]
|
||||
payslip.write({'line_ids': lines, 'number': number})
|
||||
payslip.write({'line_ids': lines, 'number': number, 'state': 'verify'})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def get_worked_day_lines(self, contracts, date_from, date_to):
|
||||
"""
|
||||
@param contract: Browse record of contracts
|
||||
@return: returns a list of dict containing the input that should be applied for the given contract between date_from and date_to
|
||||
@return: returns a list of dict containing the worked days that should be applied for the given contract between date_from and date_to
|
||||
"""
|
||||
res = []
|
||||
# fill only if the contract as a working schedule linked
|
||||
@@ -151,63 +157,23 @@ class HrPayslip(models.Model):
|
||||
day_from = datetime.combine(fields.Date.from_string(date_from), time.min)
|
||||
day_to = datetime.combine(fields.Date.from_string(date_to), time.max)
|
||||
|
||||
# compute leave days
|
||||
leaves = {}
|
||||
calendar = contract.resource_calendar_id
|
||||
tz = timezone(calendar.tz)
|
||||
day_leave_intervals = contract.employee_id.list_leaves(day_from, day_to, calendar=contract.resource_calendar_id)
|
||||
for day, hours, leave in day_leave_intervals:
|
||||
holiday = leave.holiday_id
|
||||
current_leave_struct = leaves.setdefault(holiday.holiday_status_id, {
|
||||
'name': holiday.holiday_status_id.name,
|
||||
'sequence': 5,
|
||||
'code': holiday.holiday_status_id.code or holiday.holiday_status_id.name,
|
||||
'number_of_days': 0.0,
|
||||
'number_of_hours': 0.0,
|
||||
'contract_id': contract.id,
|
||||
})
|
||||
current_leave_struct['number_of_hours'] += hours
|
||||
work_hours = calendar.get_work_hours_count(
|
||||
tz.localize(datetime.combine(day, time.min)),
|
||||
tz.localize(datetime.combine(day, time.max)),
|
||||
compute_leaves=False,
|
||||
)
|
||||
if work_hours:
|
||||
current_leave_struct['number_of_days'] += hours / work_hours
|
||||
|
||||
# compute worked days
|
||||
work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id)
|
||||
attendances = {
|
||||
'name': _("Normal Working Days paid at 100%"),
|
||||
'sequence': 1,
|
||||
'code': 'WORK100',
|
||||
'number_of_days': work_data['days'],
|
||||
'number_of_hours': work_data['hours'],
|
||||
'contract_id': contract.id,
|
||||
}
|
||||
benefit_types = self.env['hr.benefit.type'].search([('code', '!=', False)])
|
||||
for benefit_type in benefit_types:
|
||||
hours = contract.employee_id.get_benefit_days_data(benefit_type, day_from, day_to)['hours']
|
||||
if hours:
|
||||
line = {
|
||||
'name': benefit_type.name,
|
||||
'sequence': benefit_type.sequence,
|
||||
'code': benefit_type.code,
|
||||
'number_of_days': hours / calendar.hours_per_day, # n_days returned by benefit_days_data doesn't make sense for extra work
|
||||
'number_of_hours': hours,
|
||||
}
|
||||
res.append(line)
|
||||
|
||||
res.append(attendances)
|
||||
res.extend(leaves.values())
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def get_inputs(self, contracts, date_from, date_to):
|
||||
res = []
|
||||
|
||||
structures = contracts.get_all_structures()
|
||||
rule_ids = structures.get_all_rules()
|
||||
sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x:x[1])]
|
||||
inputs = self.env['hr.salary.rule'].browse(sorted_rule_ids).mapped('input_ids')
|
||||
|
||||
for contract in contracts:
|
||||
for input in inputs:
|
||||
input_data = {
|
||||
'name': input.name,
|
||||
'code': input.code,
|
||||
'contract_id': contract.id,
|
||||
}
|
||||
res += [input_data]
|
||||
return res
|
||||
|
||||
# YTI TODO: pass recordset as argument
|
||||
@api.model
|
||||
@@ -289,67 +255,6 @@ class HrPayslip(models.Model):
|
||||
|
||||
return list(result_dict.values())
|
||||
|
||||
# YTI TODO To rename. This method is not really an onchange, as it is not in any view
|
||||
# employee_id and contract_id could be browse records. Contract_id should be a record
|
||||
@api.multi
|
||||
def onchange_employee_id(self, date_from, date_to, employee_id=False, contract_id=False):
|
||||
#defaults
|
||||
res = {
|
||||
'value': {
|
||||
'line_ids': [],
|
||||
#delete old input lines
|
||||
'input_line_ids': [(2, x,) for x in self.input_line_ids.ids],
|
||||
#delete old worked days lines
|
||||
'worked_days_line_ids': [(2, x,) for x in self.worked_days_line_ids.ids],
|
||||
#'details_by_salary_head':[], TODO put me back
|
||||
'name': '',
|
||||
'contract_id': False,
|
||||
'struct_id': False,
|
||||
}
|
||||
}
|
||||
if (not employee_id) or (not date_from) or (not date_to):
|
||||
return res
|
||||
ttyme = datetime.combine(fields.Date.from_string(date_from), time.min)
|
||||
employee = self.env['hr.employee'].browse(employee_id)
|
||||
locale = self.env.context.get('lang') or 'en_US'
|
||||
res['value'].update({
|
||||
'name': _('Salary Slip of %s for %s') % (employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))),
|
||||
'company_id': employee.company_id.id,
|
||||
})
|
||||
|
||||
contracts = self.env['hr.contract']
|
||||
if not self.env.context.get('contract'):
|
||||
#fill with the first contract of the employee
|
||||
contracts = employee._get_contracts(date_from, date_to)
|
||||
else:
|
||||
if contract_id:
|
||||
#set the list of contract for which the input have to be filled
|
||||
contracts = self.env['hr.contract'].browse(contract_id)
|
||||
else:
|
||||
#if we don't give the contract, then the input to fill should be for all current contracts of the employee
|
||||
contracts = employee._get_contracts(date_from, date_to)
|
||||
|
||||
if not contracts:
|
||||
return res
|
||||
contract = contracts[0]
|
||||
res['value'].update({
|
||||
'contract_id': contract.id
|
||||
})
|
||||
struct = contract.struct_id
|
||||
if not struct:
|
||||
return res
|
||||
res['value'].update({
|
||||
'struct_id': struct.id,
|
||||
})
|
||||
#computation of the salary input
|
||||
worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to)
|
||||
input_line_ids = self.get_inputs(contracts, date_from, date_to)
|
||||
res['value'].update({
|
||||
'worked_days_line_ids': worked_days_line_ids,
|
||||
'input_line_ids': input_line_ids,
|
||||
})
|
||||
return res
|
||||
|
||||
@api.onchange('employee_id', 'date_from', 'date_to')
|
||||
def onchange_employee(self):
|
||||
|
||||
@@ -376,18 +281,13 @@ class HrPayslip(models.Model):
|
||||
return
|
||||
self.struct_id = self.contract_id.struct_id
|
||||
|
||||
#computation of the salary input
|
||||
#computation of the salary worked days
|
||||
worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to)
|
||||
worked_days_lines = self.worked_days_line_ids.browse([])
|
||||
for r in worked_days_line_ids:
|
||||
worked_days_lines += worked_days_lines.new(r)
|
||||
self.worked_days_line_ids = worked_days_lines
|
||||
|
||||
input_line_ids = self.get_inputs(contracts, date_from, date_to)
|
||||
input_lines = self.input_line_ids.browse([])
|
||||
for r in input_line_ids:
|
||||
input_lines += input_lines.new(r)
|
||||
self.input_line_ids = input_lines
|
||||
return
|
||||
|
||||
@api.onchange('contract_id')
|
||||
@@ -431,7 +331,7 @@ class HrPayslipLine(models.Model):
|
||||
amount_fix = fields.Float(related='salary_rule_id.amount_fix', readonly=True)
|
||||
amount_percentage = fields.Float(related='salary_rule_id.amount_percentage', readonly=True)
|
||||
appears_on_payslip = fields.Boolean(related='salary_rule_id.appears_on_payslip', readonly=True)
|
||||
category_id = fields.Many2one(related='salary_rule_id.category_id', readonly=True)
|
||||
category_id = fields.Many2one(related='salary_rule_id.category_id', readonly=True, store=True)
|
||||
register_id = fields.Many2one(related='salary_rule_id.register_id', readonly=True, store=True)
|
||||
|
||||
@api.depends('quantity', 'amount', 'rate')
|
||||
@@ -462,8 +362,8 @@ class HrPayslipWorkedDays(models.Model):
|
||||
code = fields.Char(required=True, help="The code that can be used in the salary rules")
|
||||
number_of_days = fields.Float(string='Number of Days')
|
||||
number_of_hours = fields.Float(string='Number of Hours')
|
||||
contract_id = fields.Many2one('hr.contract', string='Contract', required=True,
|
||||
help="The contract for which applied this input")
|
||||
contract_id = fields.Many2one(related='payslip_id.contract_id', string='Contract', required=True,
|
||||
help="The contract for which applied this worked days")
|
||||
|
||||
|
||||
class HrPayslipInput(models.Model):
|
||||
@@ -478,7 +378,7 @@ class HrPayslipInput(models.Model):
|
||||
amount = fields.Float(help="It is used in computation. For e.g. A rule for sales having "
|
||||
"1% commission of basic salary for per product can defined in expression "
|
||||
"like result = inputs.SALEURO.amount * contract.wage*0.01.")
|
||||
contract_id = fields.Many2one('hr.contract', string='Contract', required=True,
|
||||
contract_id = fields.Many2one(related='payslip_id.contract_id', string='Contract', required=True,
|
||||
help="The contract for which applied this input")
|
||||
|
||||
|
||||
@@ -491,7 +391,8 @@ class HrPayslipRun(models.Model):
|
||||
states={'draft': [('readonly', False)]})
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('close', 'Close'),
|
||||
('verify', 'Verify'),
|
||||
('close', 'Done'),
|
||||
], string='Status', index=True, readonly=True, copy=False, default='draft')
|
||||
date_start = fields.Date(string='Date From', required=True, readonly=True,
|
||||
states={'draft': [('readonly', False)]}, default=lambda self: fields.Date.to_string(date.today().replace(day=1)))
|
||||
@@ -501,6 +402,11 @@ class HrPayslipRun(models.Model):
|
||||
credit_note = fields.Boolean(string='Credit Note', readonly=True,
|
||||
states={'draft': [('readonly', False)]},
|
||||
help="If its checked, indicates that all payslips generated from here are refund payslips.")
|
||||
payslip_count = fields.Integer(compute='_compute_payslip_count')
|
||||
|
||||
def _compute_payslip_count(self):
|
||||
for payslip_run in self:
|
||||
payslip_run.payslip_count = len(self.slip_ids)
|
||||
|
||||
@api.multi
|
||||
def draft_payslip_run(self):
|
||||
@@ -510,3 +416,13 @@ class HrPayslipRun(models.Model):
|
||||
def close_payslip_run(self):
|
||||
self.mapped('slip_ids').filtered(lambda payslip: payslip.state != 'done').action_payslip_done()
|
||||
return self.write({'state': 'close'})
|
||||
|
||||
def action_open_payslips(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "hr.payslip",
|
||||
"views": [[False, "tree"], [False, "form"]],
|
||||
"domain": [['id', 'in', self.slip_ids.ids]],
|
||||
"name": "Payslips",
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ class HrSalaryRule(models.Model):
|
||||
# rules: object containing the rules code (previously computed)
|
||||
# categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category).
|
||||
# worked_days: object containing the computed worked days
|
||||
# inputs: object containing the computed inputs
|
||||
# inputs: object containing the computed inputs.
|
||||
|
||||
# Note: returned value have to be set in the variable 'result'
|
||||
|
||||
@@ -170,7 +170,6 @@ class HrSalaryRule(models.Model):
|
||||
child_ids = fields.One2many('hr.salary.rule', 'parent_rule_id', string='Child Salary Rule', copy=True)
|
||||
register_id = fields.Many2one('hr.contribution.register', string='Contribution Register',
|
||||
help="Eventual third party involved in the salary payment of the employees.")
|
||||
input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True)
|
||||
note = fields.Text(string='Description')
|
||||
|
||||
@api.constrains('parent_rule_id')
|
||||
@@ -239,11 +238,3 @@ class HrSalaryRule(models.Model):
|
||||
except:
|
||||
raise UserError(_('Wrong python condition defined for salary rule %s (%s).') % (self.name, self.code))
|
||||
|
||||
|
||||
class HrRuleInput(models.Model):
|
||||
_name = 'hr.rule.input'
|
||||
_description = 'Salary Rule Input'
|
||||
|
||||
name = fields.Char(string='Description', required=True)
|
||||
code = fields.Char(required=True, help="The code that can be used in the salary rules")
|
||||
input_id = fields.Many2one('hr.salary.rule', string='Salary Rule Input', required=True)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import models, fields
|
||||
from odoo.osv.expression import AND, OR
|
||||
|
||||
|
||||
class ResourceCalendar(models.Model):
|
||||
_inherit = 'resource.calendar'
|
||||
|
||||
# UI fields
|
||||
normal_attendance_ids = fields.One2many(
|
||||
'resource.calendar.attendance', 'calendar_id', 'Normal working Time',
|
||||
domain=[('resource_id', '=', False)])
|
||||
|
||||
extra_attendance_ids = fields.One2many(
|
||||
'resource.calendar.attendance', 'calendar_id', 'Employees working Time',
|
||||
domain=[('resource_id', '!=', False)])
|
||||
|
||||
|
||||
class ResourceCalendarAttendance(models.Model):
|
||||
_inherit = 'resource.calendar.attendance'
|
||||
|
||||
def _default_benefit_type_id(self):
|
||||
return self.env.ref('hr_payroll.benefit_type_attendance', raise_if_not_found=False)
|
||||
|
||||
benefit_type_id = fields.Many2one('hr.benefit.type', 'Benefit Type', default=_default_benefit_type_id)
|
||||
|
||||
class ResourceCalendarLeave(models.Model):
|
||||
_inherit = 'resource.calendar.leaves'
|
||||
benefit_type_id = fields.Many2one('hr.benefit.type', 'Benefit Type')
|
||||
|
||||
|
||||
class ResourceMixin(models.AbstractModel):
|
||||
_inherit = "resource.mixin"
|
||||
|
||||
def get_benefit_days_data(self, benefit_type, from_datetime, to_datetime, calendar=None):
|
||||
"""
|
||||
By default the resource calendar is used, but it can be
|
||||
changed using the `calendar` argument.
|
||||
|
||||
Returns a dict {'days': n, 'hours': h} containing the number of leaves
|
||||
expressed as days and as hours.
|
||||
"""
|
||||
resource = self.resource_id
|
||||
calendar = calendar or self.resource_calendar_id
|
||||
benefit_type_ids = benefit_type.ids
|
||||
if benefit_type == self.env.ref('hr_payroll.benefit_type_attendance'): # special case for global attendances
|
||||
benefit_type_ids += [False]# no benefit type = normal/global attendance
|
||||
domain = [('benefit_type_id', 'in', benefit_type_ids)]
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
from_datetime = self._timezone_datetime(from_datetime)
|
||||
to_datetime = self._timezone_datetime(to_datetime)
|
||||
|
||||
day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource)
|
||||
# actual hours per day
|
||||
if benefit_type.is_leave:
|
||||
intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) & calendar._leave_intervals(from_datetime, to_datetime, resource, domain) # use domain to only retrieve leaves of this type
|
||||
else:
|
||||
intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource, domain) - calendar._leave_intervals(from_datetime, to_datetime, resource)
|
||||
|
||||
return self._get_days_data(intervals, day_total)
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import report_payslip_details
|
||||
from . import report_contribution_register
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
#-*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
class PayslipDetailsReport(models.AbstractModel):
|
||||
_name = 'report.hr_payroll.report_payslipdetails'
|
||||
_description = 'Payslip Details Report'
|
||||
|
||||
def get_details_by_rule_category(self, payslip_lines):
|
||||
PayslipLine = self.env['hr.payslip.line']
|
||||
RuleCateg = self.env['hr.salary.rule.category']
|
||||
|
||||
def get_recursive_parent(current_rule_category, rule_categories=None):
|
||||
if rule_categories:
|
||||
rule_categories = current_rule_category | rule_categories
|
||||
else:
|
||||
rule_categories = current_rule_category
|
||||
|
||||
if current_rule_category.parent_id:
|
||||
return get_recursive_parent(current_rule_category.parent_id, rule_categories)
|
||||
else:
|
||||
return rule_categories
|
||||
|
||||
res = {}
|
||||
result = {}
|
||||
|
||||
if payslip_lines:
|
||||
self.env.cr.execute("""
|
||||
SELECT pl.id, pl.category_id, pl.slip_id FROM hr_payslip_line as pl
|
||||
LEFT JOIN hr_salary_rule_category AS rc on (pl.category_id = rc.id)
|
||||
WHERE pl.id in %s
|
||||
GROUP BY rc.parent_id, pl.sequence, pl.id, pl.category_id
|
||||
ORDER BY pl.sequence, rc.parent_id""",
|
||||
(tuple(payslip_lines.ids),))
|
||||
for x in self.env.cr.fetchall():
|
||||
result.setdefault(x[2], {})
|
||||
result[x[2]].setdefault(x[1], [])
|
||||
result[x[2]][x[1]].append(x[0])
|
||||
for payslip_id, lines_dict in result.items():
|
||||
res.setdefault(payslip_id, [])
|
||||
for rule_categ_id, line_ids in lines_dict.items():
|
||||
rule_categories = RuleCateg.browse(rule_categ_id)
|
||||
lines = PayslipLine.browse(line_ids)
|
||||
level = 0
|
||||
for parent in get_recursive_parent(rule_categories):
|
||||
res[payslip_id].append({
|
||||
'rule_category': parent.name,
|
||||
'name': parent.name,
|
||||
'code': parent.code,
|
||||
'level': level,
|
||||
'total': sum(lines.mapped('total')),
|
||||
})
|
||||
level += 1
|
||||
for line in lines:
|
||||
res[payslip_id].append({
|
||||
'rule_category': line.name,
|
||||
'name': line.name,
|
||||
'code': line.code,
|
||||
'total': line.total,
|
||||
'level': level
|
||||
})
|
||||
return res
|
||||
|
||||
def get_lines_by_contribution_register(self, payslip_lines):
|
||||
result = {}
|
||||
res = {}
|
||||
for line in payslip_lines.filtered('register_id'):
|
||||
result.setdefault(line.slip_id.id, {})
|
||||
result[line.slip_id.id].setdefault(line.register_id, line)
|
||||
result[line.slip_id.id][line.register_id] |= line
|
||||
for payslip_id, lines_dict in result.items():
|
||||
res.setdefault(payslip_id, [])
|
||||
for register, lines in lines_dict.items():
|
||||
res[payslip_id].append({
|
||||
'register_name': register.name,
|
||||
'total': sum(lines.mapped('total')),
|
||||
})
|
||||
for line in lines:
|
||||
res[payslip_id].append({
|
||||
'name': line.name,
|
||||
'code': line.code,
|
||||
'quantity': line.quantity,
|
||||
'amount': line.amount,
|
||||
'total': line.total,
|
||||
})
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
payslips = self.env['hr.payslip'].browse(docids)
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'hr.payslip',
|
||||
'docs': payslips,
|
||||
'data': data,
|
||||
'get_details_by_rule_category': self.get_details_by_rule_category(payslips.mapped('details_by_salary_rule_category').filtered(lambda r: r.appears_on_payslip)),
|
||||
'get_lines_by_contribution_register': self.get_lines_by_contribution_register(payslips.mapped('line_ids').filtered(lambda r: r.appears_on_payslip)),
|
||||
}
|
||||
@@ -33,5 +33,16 @@
|
||||
<field name="groups" eval="[(4, ref('hr_payroll.group_hr_payroll_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_benefit_validated" model="ir.rule">
|
||||
<field name="name">hr.benefit: only non validated benefits updated</field>
|
||||
<field name="model_id" ref="model_hr_benefit"/>
|
||||
<field name="domain_force">[('state', '!=', 'validated')]</field>
|
||||
<field name="groups" eval="[(4, ref('hr_payroll.group_hr_payroll_manager'))]"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="perm_read" eval="0"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -8,7 +8,8 @@ access_hr_payslip_line,hr.payslip.line,model_hr_payslip_line,hr_payroll.group_hr
|
||||
access_hr_payslip_input_user,hr.payslip.input.user,model_hr_payslip_input,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_hr_payslip_worked_days_officer,hr.payslip.worked_days.officer,model_hr_payslip_worked_days,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_hr_payslip_run,hr.payslip.run,model_hr_payslip_run,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_hr_rule_input_officer,hr.rule.input.office,model_hr_rule_input,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_hr_salary_rule_user,hr.salary.rule.user,model_hr_salary_rule,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_hr_contract_advantage_template,hr.contract.advantage.template.user,model_hr_contract_advantage_template,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_hr_contract_advantage_template_hr_user,hr.contract.advantage.template.hr.user,model_hr_contract_advantage_template,hr.group_hr_user,1,0,0,0
|
||||
access_hr_benefit,access_hr_benefit,model_hr_benefit,group_hr_payroll_user,1,1,1,1
|
||||
access_hr_benefit_type,access_hr_benefit_type,model_hr_benefit_type,group_hr_payroll_user,1,1,1,1
|
||||
|
@@ -0,0 +1,128 @@
|
||||
odoo.define('hr_payroll.benefit.view_custo', function(require) {
|
||||
'use strict';
|
||||
|
||||
var core = require('web.core');
|
||||
var CalendarController = require("web.CalendarController");
|
||||
|
||||
var _t = core._t;
|
||||
CalendarController.include({
|
||||
|
||||
update: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self._renderBenefitButtons();
|
||||
});
|
||||
},
|
||||
|
||||
_renderGenerateButton: function(date_from, date_to, employee_ids, secondary) {
|
||||
var self = this;
|
||||
var primary = !secondary ? 'btn-primary' : 'btn-secondary';
|
||||
var txt = _t("Generate Benefits");
|
||||
this.$buttons.find('.o_calendar_button_month').after(
|
||||
$('<button class="btn ' + primary + ' btn-benefit" type="button">'+ txt +'</button>')
|
||||
.off('click')
|
||||
.on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
var date_fmt = 'YYYY-MM-DD HH:mm:ss';
|
||||
var options = {
|
||||
on_close: function () {
|
||||
self.reload();
|
||||
},
|
||||
};
|
||||
self.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
name: txt,
|
||||
res_model: 'hr.benefit.employees',
|
||||
view_type: 'form',
|
||||
views: [[false,'form']],
|
||||
target: 'new',
|
||||
context: {
|
||||
'start_benefits': date_from.format(date_fmt),
|
||||
'stop_benefits':date_to.format(date_fmt),
|
||||
},
|
||||
}, options);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
_renderBenefitButtons: function () {
|
||||
if (this.modelName !== "hr.benefit") {
|
||||
return;
|
||||
}
|
||||
|
||||
var firstDay = this.model.data.target_date.clone().startOf('month');
|
||||
var lastDay = this.model.data.target_date.clone().endOf('month');
|
||||
var events = this._checkDataInRange(firstDay, lastDay, this.model.data.data);
|
||||
var is_validated = this._checkValidation(events);
|
||||
this.$buttons.find('.btn-benefit').remove();
|
||||
var employee_ids = _.map(events, function (event) { return event.record.employee_id[0]; });
|
||||
employee_ids = _.uniq(employee_ids);
|
||||
var self = this;
|
||||
if (this.model.data.domain.length !== 0) { // select by default the employee in the domain
|
||||
var employee_search_id = (this.model.data.domain[0][0] === 'employee_id' && this.model.data.domain[0][1] === '=')? [this.model.data.domain[0][2]]: null;
|
||||
}
|
||||
if (events.length === 0) { // Generate button
|
||||
this._renderGenerateButton(firstDay, lastDay, employee_search_id);
|
||||
} else {
|
||||
this._renderGenerateButton(firstDay, lastDay, employee_ids, true);
|
||||
}
|
||||
if (is_validated && events.length !== 0) { // Generate Payslip button
|
||||
this.$buttons.find('.o_calendar_button_month').after(
|
||||
$('<button class="btn btn-primary btn-benefit" type="button">'+ _t('Generate Payslips') +'</button>')
|
||||
.off('click')
|
||||
// action_hr_payslip_by_employees
|
||||
.on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
var date_fmt = 'YYYY-MM-DD';
|
||||
self.do_action('hr_payroll.action_hr_payslip_by_employees', {
|
||||
additional_context: {
|
||||
default_employee_ids: employee_ids || [],
|
||||
default_date_start: firstDay.format(date_fmt),
|
||||
default_date_end: lastDay.format(date_fmt),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
else if (!is_validated) { // Validate button
|
||||
this.$buttons.find('.o_calendar_button_month').after(
|
||||
$('<button class="btn btn-primary btn-benefit" type="button">'+ _t('Validate Benefits') +'</button>')
|
||||
.off('click')
|
||||
.on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
self._rpc({
|
||||
model: 'hr.benefit',
|
||||
method: 'action_validate',
|
||||
args: [_.map(events, function (event) { return event.record.id; })],
|
||||
}).then(function () {
|
||||
return self.reload();
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
_checkDataInRange: function (firstDay, lastDay, events) {
|
||||
var res = _.filter(events, function (event) {
|
||||
// Filter records that are not inside the current month
|
||||
// (because in calendar view some days of prev. and next month are visible)
|
||||
return event.record.date_start.isBefore(lastDay) && event.record.date_stop.isAfter(firstDay);
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
_checkValidation: function (records) {
|
||||
return _.every(records, function (record) {
|
||||
return record.record.state === 'validated';
|
||||
});
|
||||
},
|
||||
|
||||
renderButtons: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._renderBenefitButtons();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_payslip_flow
|
||||
from . import test_benefit
|
||||
|
||||
@@ -18,7 +18,6 @@ class TestPayslipBase(TransactionCase):
|
||||
self.prof_tax_rule_id = self.ref('hr_payroll.hr_salary_rule_professionaltax1')
|
||||
self.pf_rule_id = self.ref('hr_payroll.hr_salary_rule_providentfund1')
|
||||
self.mv_rule_id = self.ref('hr_payroll.hr_salary_rule_meal_voucher')
|
||||
self.comm_rule_id = self.ref('hr_payroll.hr_salary_rule_sales_commission')
|
||||
|
||||
# I create a new employee "Richard"
|
||||
self.richard_emp = self.env['hr.employee'].create({
|
||||
@@ -36,7 +35,7 @@ class TestPayslipBase(TransactionCase):
|
||||
'company_id': self.ref('base.main_company'),
|
||||
'rule_ids': [(4, self.hra_rule_id), (4, self.conv_rule_id),
|
||||
(4, self.prof_tax_rule_id), (4, self.pf_rule_id),
|
||||
(4, self.mv_rule_id), (4, self.comm_rule_id)],
|
||||
(4, self.mv_rule_id)],
|
||||
})
|
||||
|
||||
# I create a contract for "Richard"
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# # -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import exceptions
|
||||
from odoo.tests.common import tagged
|
||||
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
|
||||
|
||||
|
||||
@tagged('benefit')
|
||||
class TestBenefit(TestPayslipBase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestBenefit, self).setUp()
|
||||
self.tz = pytz.timezone(self.richard_emp.tz)
|
||||
self.start = datetime.strptime('2015-11-01 01:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
self.end = datetime.strptime('2015-11-30 23:59:59', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
self.resource_calendar_id = self.env['resource.calendar'].create({'name': 'Zboub'})
|
||||
self.env['hr.contract'].create({
|
||||
'date_start': self.start - relativedelta(days=5),
|
||||
'name': 'dodo',
|
||||
'resource_calendar_id': self.resource_calendar_id.id,
|
||||
'wage': 1000,
|
||||
'employee_id': self.richard_emp.id,
|
||||
'state': 'open',
|
||||
})
|
||||
self.richard_emp.resource_calendar_id = self.resource_calendar_id
|
||||
|
||||
self.benefit_type_leave = self.env['hr.benefit.type'].create({
|
||||
'name': 'Leave',
|
||||
'is_leave': True,
|
||||
'code': 'LEAVE100'
|
||||
})
|
||||
self.leave_type = self.env['hr.leave.type'].create({
|
||||
'name': 'Legal Leaves',
|
||||
'time_type': 'leave',
|
||||
'allocation_type': 'no',
|
||||
'benefit_type_id': self.benefit_type_leave.id
|
||||
})
|
||||
self.benefit_type = self.env['hr.benefit.type'].create({
|
||||
'name': 'attendance',
|
||||
'is_leave': False,
|
||||
'code': 'LEAVE100'
|
||||
})
|
||||
|
||||
def test_no_duplicate(self):
|
||||
self.richard_emp.generate_benefit(self.start, self.end)
|
||||
pou1 = self.env['hr.benefit'].search_count([])
|
||||
self.richard_emp.generate_benefit(self.start, self.end)
|
||||
pou2 = self.env['hr.benefit'].search_count([])
|
||||
self.assertEqual(pou1, pou2, "Benefits should not be duplicated")
|
||||
|
||||
def test_benefit(self):
|
||||
|
||||
self.richard_emp.generate_benefit(self.start, self.end)
|
||||
|
||||
attendance_nb = len(self.resource_calendar_id._attendance_intervals(self.start, self.end))
|
||||
benefit_nb = self.env['hr.benefit'].search_count([('employee_id', '=', self.richard_emp.id)])
|
||||
self.assertEqual(attendance_nb, benefit_nb, "One benefit should be generated for each calendar attendance")
|
||||
|
||||
def test_split_benefit_by_day(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
|
||||
# Benefit of type attendance should be split in three
|
||||
benefit = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
|
||||
benefits = benefit.split_by_day()
|
||||
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
|
||||
self.assertEqual(pytz.utc.localize(benefits[0].date_start), self.tz.localize(datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[0].date_stop), self.tz.localize(datetime.strptime('2015-11-01 23:59:59', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
self.assertEqual(pytz.utc.localize(benefits[1].date_start), self.tz.localize(datetime.strptime('2015-11-02 00:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[1].date_stop), self.tz.localize(datetime.strptime('2015-11-02 23:59:59', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
self.assertEqual(pytz.utc.localize(benefits[2].date_start), self.tz.localize(datetime.strptime('2015-11-03 00:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[2].date_stop), self.tz.localize(datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
# Test with end at mid-night -> should not create benefit starting and ending at the same time (at 00:00)
|
||||
start = datetime.strptime('2013-11-01 00:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2013-11-04 00:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
|
||||
benefit = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benefits = benefit.split_by_day()
|
||||
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
|
||||
self.assertEqual(pytz.utc.localize(benefits[0].date_start), self.tz.localize(datetime.strptime('2013-11-01 00:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[0].date_stop), self.tz.localize(datetime.strptime('2013-11-01 23:59:59', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
self.assertEqual(pytz.utc.localize(benefits[1].date_start), self.tz.localize(datetime.strptime('2013-11-02 00:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[1].date_stop), self.tz.localize(datetime.strptime('2013-11-02 23:59:59', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
self.assertEqual(pytz.utc.localize(benefits[2].date_start), self.tz.localize(datetime.strptime('2013-11-03 00:00:00', '%Y-%m-%d %H:%M:%S')))
|
||||
self.assertEqual(pytz.utc.localize(benefits[2].date_stop), self.tz.localize(datetime.strptime('2013-11-03 23:59:59', '%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
|
||||
def test_duplicate_global_benefit_to_attendance(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef._duplicate_to_calendar()
|
||||
attendance_nb = self.env['resource.calendar.attendance'].search_count([
|
||||
('date_from', '>=', start.date()),
|
||||
('date_to', '<=', end.date())
|
||||
])
|
||||
self.assertEqual(attendance_nb, 0, "It should not duplicate the 'normal/global' benefit type")
|
||||
|
||||
def test_duplicate_benefit_to_attendance(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
|
||||
# Benefit (not leave) should be split in three attendance
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef._duplicate_to_calendar()
|
||||
attendance_nb = self.env['resource.calendar.attendance'].search_count([
|
||||
('date_from', '>=', start.date()),
|
||||
('date_to', '<=', end.date())
|
||||
])
|
||||
self.assertEqual(attendance_nb, 3, "It should create one calendar attendance per day")
|
||||
self.assertTrue(self.env['resource.calendar.attendance'].search([
|
||||
('date_from', '=', datetime.strptime('2015-11-01', '%Y-%m-%d').date()),
|
||||
('date_to', '=', datetime.strptime('2015-11-01', '%Y-%m-%d').date()),
|
||||
('hour_from', '=', 9.0),
|
||||
('hour_to', '>=', 23.9)
|
||||
]))
|
||||
self.assertTrue(self.env['resource.calendar.attendance'].search([
|
||||
('date_from', '=', datetime.strptime('2015-11-02', '%Y-%m-%d').date()),
|
||||
('date_to', '=', datetime.strptime('2015-11-02', '%Y-%m-%d').date()),
|
||||
('hour_from', '=', 0.0),
|
||||
('hour_to', '>=', 23.9)
|
||||
]))
|
||||
self.assertTrue(self.env['resource.calendar.attendance'].search([
|
||||
('date_from', '=', datetime.strptime('2015-11-03', '%Y-%m-%d').date()),
|
||||
('date_to', '=', datetime.strptime('2015-11-03', '%Y-%m-%d').date()),
|
||||
('hour_from', '=', 0.0),
|
||||
('hour_to', '=', 18.0)
|
||||
]))
|
||||
|
||||
def test_create_benefit_leave(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': 'Richard leave from benef',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type_leave.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')])
|
||||
self.assertTrue(calendar_leave, "It should have created a leave in the calendar")
|
||||
self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type")
|
||||
|
||||
def test_validate_conflict_benefit(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-01 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
benef1 = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
|
||||
'date_start': start,
|
||||
'date_stop': end + relativedelta(hours=5),
|
||||
})
|
||||
benef2 = self.env['hr.benefit'].create({
|
||||
'name': '2',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
|
||||
'date_start': start + relativedelta(hours=3),
|
||||
'date_stop': end,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others")
|
||||
self.assertTrue(benef1.display_warning)
|
||||
self.assertNotEqual(benef1.state, 'validated')
|
||||
|
||||
def test_validate_non_approved_leave_benefit(self):
|
||||
benef1 = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type_leave.id,
|
||||
'date_start': self.start,
|
||||
'date_stop': self.end,
|
||||
})
|
||||
leave_1 = self.env['hr.leave'].create({
|
||||
'name': 'Doctor Appointment',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'date_from': self.start - relativedelta(days=1),
|
||||
'date_to': self.start + relativedelta(days=1),
|
||||
'number_of_days': 2,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves")
|
||||
self.assertTrue(benef1.display_warning)
|
||||
|
||||
def test_validate_undefined_benefit(self):
|
||||
benef1 = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'date_start': self.start,
|
||||
'date_stop': self.end,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type")
|
||||
|
||||
def test_approve_leave_benefit(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
leave = self.env['hr.leave'].create({
|
||||
'name': 'Doctor Appointment',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'date_from': start,
|
||||
'date_to': start + relativedelta(days=1),
|
||||
'number_of_days': 2,
|
||||
})
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
'leave_id': leave.id
|
||||
})
|
||||
leave.action_approve()
|
||||
|
||||
new_leave_benef = self.env['hr.benefit'].search([
|
||||
('date_start', '=', datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S')),
|
||||
('date_stop', '=', datetime.strptime('2015-11-02 09:00:00', '%Y-%m-%d %H:%M:%S')),
|
||||
('benefit_type_id.is_leave', '=', True)
|
||||
])
|
||||
|
||||
new_benef = self.env['hr.benefit'].search([
|
||||
('date_start', '=', datetime.strptime('2015-11-02 09:00:01', '%Y-%m-%d %H:%M:%S')),
|
||||
('date_stop', '=', end),
|
||||
('benefit_type_id.is_leave', '=', False)
|
||||
])
|
||||
|
||||
self.assertTrue(new_benef, "It should have created a benefit for the last two days")
|
||||
self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day")
|
||||
|
||||
self.assertTrue(benef.action_validate((new_benef|new_leave_benef).ids), "It should be able to validate the benefits")
|
||||
|
||||
def test_refuse_leave_benefit(self):
|
||||
start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-03 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
leave = self.env['hr.leave'].create({
|
||||
'name': 'Doctor Appointment',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'date_from': start,
|
||||
'date_to': start + relativedelta(days=1),
|
||||
'number_of_days': 2,
|
||||
})
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
'leave_id': leave.id
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve")
|
||||
leave.action_refuse()
|
||||
self.assertFalse(benef.display_warning, "It should not have an error")
|
||||
|
||||
def test_time_normal_benefit(self):
|
||||
# Normal attendances (global to all employees)
|
||||
data = self.richard_emp.get_benefit_days_data(self.env.ref('hr_payroll.benefit_type_attendance'), self.start, self.end)
|
||||
self.assertEqual(data['hours'], 168.0)
|
||||
|
||||
def test_time_extra_benefit(self):
|
||||
start = datetime.strptime('2015-11-01 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
end = datetime.strptime('2015-11-01 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz)
|
||||
benef = self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
data = self.richard_emp.get_benefit_days_data(self.benefit_type, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 7.0)
|
||||
|
||||
def test_time_week_leave_benefit(self):
|
||||
# /!\ this is a week day => it exists an calendar attendance at this time
|
||||
start = datetime.strptime('2015-11-02 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) # UTC
|
||||
end = datetime.strptime('2015-11-02 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc)
|
||||
leave_benef = self.env['hr.benefit'].create({
|
||||
'name': '1leave',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type_leave.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
leave_benef.action_validate(leave_benef.ids)
|
||||
data = self.richard_emp.get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked")
|
||||
|
||||
def test_time_weekend_leave_benefit(self):
|
||||
# /!\ this is in the weekend => no calendar attendance at this time
|
||||
start = datetime.strptime('2015-11-01 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) # UTC
|
||||
end = datetime.strptime('2015-11-01 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc)
|
||||
leave_benef = self.env['hr.benefit'].create({
|
||||
'name': '1leave',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.benefit_type_leave.id,
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
leave_benef.action_validate(leave_benef.ids)
|
||||
data = self.richard_emp.get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked")
|
||||
@@ -71,9 +71,6 @@ class TestPayslipFlow(TestPayslipBase):
|
||||
# I print the payslip report
|
||||
data, data_format = self.env.ref('hr_payroll.action_report_payslip').render(richard_payslip.ids)
|
||||
|
||||
# I print the payslip details report
|
||||
data, data_format = self.env.ref('hr_payroll.payslip_details_report').render(richard_payslip.ids)
|
||||
|
||||
# I print the contribution register report
|
||||
context = {'model': 'hr.contribution.register', 'active_ids': [self.ref('hr_payroll.hr_houserent_register')]}
|
||||
test_reports.try_report_action(self.env.cr, self.env.uid, 'action_payslip_lines_contribution_register', context=context, our_module='hr_payroll')
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_backend" inherit_id="web.assets_backend">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/hr_payroll/static/src/js/benefit.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,163 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record model="ir.actions.server" id="generate_benefit">
|
||||
<field name="name">Generate Benefit</field>
|
||||
<field name="model_id" ref="hr.model_hr_employee"/>
|
||||
<field name="binding_model_id" ref="hr.model_hr_employee"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records:
|
||||
records.generate_benefit()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_benefit_action" model="ir.actions.act_window">
|
||||
<field name="name">Benefit</field>
|
||||
<field name="res_model">hr.benefit</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">calendar,tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_benefit_calendar" model="ir.ui.view">
|
||||
<field name="name">hr.benefit.calendar</field>
|
||||
<field name="model">hr.benefit</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar string="Benefit"
|
||||
date_start="date_start"
|
||||
date_stop="date_stop"
|
||||
mode="month"
|
||||
quick_add="False"
|
||||
color="benefit_type_id"
|
||||
event_limit="5">
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="employee_id" invisible="1"/>
|
||||
<field name="display_warning" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<templates>
|
||||
<t t-name="calendar-box">
|
||||
<t t-set="color" t-value="widget.getColor(event.color_index)"/>
|
||||
<div t-att-style="typeof color === 'string' ? ('background-color:'+color)+';' : ''" t-attf-class="#{record.is_highlighted && record.is_highlighted.value ? 'o_event_hightlight' : ''} #{typeof color === 'number' ? 'o_calendar_color_'+color : ''}">
|
||||
<field name="name"/>
|
||||
<div class="fc-time"/>
|
||||
<div class="o_fields">
|
||||
<t t-if="record.display_warning">
|
||||
<span class="fa fa-exclamation-triangle mr-2" aria-label="Conflict" title="error"/>
|
||||
</t>
|
||||
<t t-if="record.state == 'validated'">
|
||||
<span class="fa fa-check-circle-o" aria-label="Validated" title="validated"/>
|
||||
<t t-esc="' '"/>
|
||||
</t>
|
||||
<t t-foreach="widget.displayFields" t-as="name">
|
||||
<t t-esc="format(record, name)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_benefit_form" model="ir.ui.view">
|
||||
<field name="name">hr.benefit.form</field>
|
||||
<field name="model">hr.benefit</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Benefit" >
|
||||
<header>
|
||||
<field name="display_warning" invisible="1"/>
|
||||
<field name="leave_id" invisible="1"/>
|
||||
<button string="Go To Conflicting Leave" name="action_leave" type="object" attrs="{'invisible': ['|', '!',('display_warning', '=', True), '!', ('leave_id', '!=', False)]}"/>
|
||||
<field name="state" widget="statusbar" options="{'clickable': '1'}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div attrs="{'invisible': [('display_warning', '=', False)]}">
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': ['!', ('benefit_type_id', '=', False)]}">
|
||||
This benefit cannot be validated. The benefit type is undefined.
|
||||
</div>
|
||||
<div attrs="{'invisible': [('benefit_type_id', '=', False)]}">
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': [('leave_id', '=', False)]}">
|
||||
This benefit cannot be validated. There is a leave to approve (or refuse) at the same time.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': ['!', ('leave_id', '=', False)]}">
|
||||
This benefit cannot be validated. It is conflicting with at least one benefit. <br/>
|
||||
Two benefits of the same employee cannot overlap at the same time.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Benefit Name" attrs="{'readonly': [('state', '=', 'validated')]}"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id" attrs="{'readonly': [('state', '!=', 'draft')]}" />
|
||||
<field name="benefit_type_id" attrs="{'readonly': [('state', '=', 'validated')]}" options="{'no_create': True, 'no_open': True}"/>
|
||||
<field name="leave_id" attrs="{'invisible': [('leave_id', '=', False)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_start" attrs="{'readonly': [('state', '!=', 'draft')]}" />
|
||||
<field name="date_stop" attrs="{'readonly': [('state', '!=', 'draft')]}" />
|
||||
<field name="duration" attrs="{'readonly': [('state', '!=', 'draft')]}" />
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_benefit_filter" model="ir.ui.view">
|
||||
<field name="name">hr.benefit.filter</field>
|
||||
<field name="model">hr.benefit</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Benefit">
|
||||
<field name="employee_id"/>
|
||||
<field name="name"/>
|
||||
<filter string="My Benefits" domain="[('employee_id.user_id','=',uid)]" name="my_benefits"/>
|
||||
<filter name="group_employee" string="Employee" context="{'group_by':'employee_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_benefit_type_form" model="ir.ui.view">
|
||||
<field name="name">hr.benefit.type.form</field>
|
||||
<field name="model">hr.benefit.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Benefit Type" >
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Benefit Type Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="is_leave"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="hr_menu_benefit_menu" name="Benefits" action="hr_benefit_action" parent="menu_hr_payroll_root" sequence="25"/>
|
||||
|
||||
<record id="action_view_hr_benefit_type" model="ir.actions.act_window">
|
||||
<field name="name">Benefit Types</field>
|
||||
<field name="res_model">hr.benefit.type</field>
|
||||
<field name="view_mode">tree,kanban,form</field>
|
||||
</record>
|
||||
<menuitem
|
||||
id="menu_hr_benefit_type_view"
|
||||
action="action_view_hr_benefit_type"
|
||||
parent="menu_hr_payroll_configuration"
|
||||
sequence="25"
|
||||
/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='UTF-8' ?>
|
||||
<odoo>
|
||||
<!-- Benefit type on hr leave -->
|
||||
<record id="benefit_type_leave_form_inherit" model="ir.ui.view">
|
||||
<field name="name">benefit.type.leave.form.inherit</field>
|
||||
<field name="model">hr.leave.type</field>
|
||||
<field name="inherit_id" ref="hr_holidays.edit_holiday_status_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='calendar']" position="after">
|
||||
<group name="benefit" string="Benefits">
|
||||
<field name="benefit_type_id"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -19,13 +19,4 @@
|
||||
print_report_name="'Payslip - %s' % (object.employee_id.name)"
|
||||
/>
|
||||
|
||||
<report
|
||||
id="payslip_details_report"
|
||||
model="hr.payslip"
|
||||
string="PaySlip Details"
|
||||
report_type="qweb-pdf"
|
||||
name="hr_payroll.report_payslipdetails"
|
||||
file="hr_payroll.report_payslipdetails"
|
||||
print_report_name="'Payslip Details - %s' % (object.employee_id.name)"
|
||||
/>
|
||||
</odoo>
|
||||
|
||||
@@ -128,12 +128,12 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Payslip">
|
||||
<header>
|
||||
<button string="Confirm" name="action_payslip_done" type="object" states="draft" class="oe_highlight"/>
|
||||
<button string="Confirm" name="action_payslip_done" type="object" states="verify" class="oe_highlight"/>
|
||||
<button string="Refund" name="refund_sheet" states="confirm,done" type='object' />
|
||||
<button string="Set to Draft" name="action_payslip_draft" type="object" states="cancel"/>
|
||||
<button string="Compute Sheet" name="compute_sheet" type="object" states="draft" class="oe_highlight"/>
|
||||
<button string="Cancel Payslip" name="action_payslip_cancel" type="object" states="draft,hr_check,confirm,verify"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirm"/>
|
||||
<button string="Cancel Payslip" name="action_payslip_cancel" type="object" states="draft,confirm,verify"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,verify,confirm"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
@@ -157,7 +157,7 @@
|
||||
<field name="number"/>
|
||||
<field name="struct_id" attrs="{'required':[('contract_id','!=',False)]}"/>
|
||||
<field name="name"/>
|
||||
<field name="credit_note"/>
|
||||
<field name="credit_note" invisible="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Worked Days & Inputs">
|
||||
@@ -168,7 +168,7 @@
|
||||
<field name="code"/>
|
||||
<field name="number_of_days" sum="Total Working Days"/>
|
||||
<field name="number_of_hours"/>
|
||||
<field name="contract_id"/>
|
||||
<field name="contract_id" invisible="True"/>
|
||||
<field name="sequence" invisible="True"/>
|
||||
</tree>
|
||||
<form string="Worked Day">
|
||||
@@ -188,7 +188,7 @@
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="amount"/>
|
||||
<field name="contract_id"/>
|
||||
<field name="contract_id" invisible="True"/>
|
||||
<field name="sequence" invisible="True"/>
|
||||
</tree>
|
||||
<form string="Payslip Line">
|
||||
@@ -230,17 +230,7 @@
|
||||
</form>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Details By Salary Rule Category">
|
||||
<field name="details_by_salary_rule_category" context="{'group_by':'category_id'}" domain="[('appears_on_payslip', '=', True)]">
|
||||
<tree string="Payslip Lines" decoration-info="total == 0">
|
||||
<field name="category_id"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="total"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Accounting Information">
|
||||
<page string="Accounting Information">
|
||||
<group>
|
||||
<group string="Miscellaneous">
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
@@ -253,7 +243,7 @@
|
||||
<div colspan="4">
|
||||
<field name="note" placeholder="Add an internal note..."/>
|
||||
</div>
|
||||
</page>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
@@ -265,15 +255,17 @@
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Payslips">
|
||||
<field name="employee_id"/>
|
||||
<field name="name" string="Payslips" filter_domain="['|',('name','ilike',self),('number','ilike',self)]"/>
|
||||
<field name="date_from"/>
|
||||
<field name="payslip_run_id"/>
|
||||
<filter string="Draft" name="draft" domain="[('state','=','draft')]" help="Draft Slip"/>
|
||||
<filter string="Done" name="done" domain="[('state','=','done')]" help="Done Slip"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="payslip_run_id"/>
|
||||
<separator/>
|
||||
<filter string="Credit Notes" name="credit_note" domain="[('credit_note', '=', True)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employees" name="employee_id" context="{'group_by':'employee_id'}"/>
|
||||
<filter string="PaySlip Batch" name="payslip_run_id" context="{'group_by':'payslip_run_id'}"/>
|
||||
<filter string="PaySlip Batch" name="group_by_batch" context="{'group_by':'payslip_run_id'}"/>
|
||||
<filter string="Companies" name="company_id" groups="base.group_multi_company" context="{'group_by':'company_id'}"/>
|
||||
<filter string="States" name="state" context="{'group_by':'state'}"/>
|
||||
</group>
|
||||
@@ -286,6 +278,7 @@
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,kanban,form</field>
|
||||
<field name="search_view_id" ref="view_hr_payslip_filter"/>
|
||||
<field name="context">{'search_default_group_by_batch': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem action="action_view_hr_payslip_form" id="menu_department_tree" parent="menu_hr_payroll_root" groups="hr_payroll.group_hr_payroll_user"/>
|
||||
@@ -367,12 +360,20 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Payslips Batches">
|
||||
<header>
|
||||
<button name="close_payslip_run" type="object" string="Close" states="draft" class="oe_highlight"/>
|
||||
<button name="%(action_hr_payslip_by_employees)d" type="action" states="draft" string="Generate Payslips" class="oe_highlight"/>
|
||||
<button string="Set to Draft" name="draft_payslip_run" type="object" states="close" />
|
||||
<button name="close_payslip_run" type="object" string="Validate" states="verify" class="oe_highlight"/>
|
||||
<button string="Set to Draft" name="draft_payslip_run" type="object" states="close"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_open_payslips" class="oe_stat_button" icon="fa-book" type="object" help="Generated Payslips" attrs="{'invisible': [('payslip_count', '=', 0)]}">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value"><field name="payslip_count"/></span>
|
||||
<span class="o_stat_text">Payslips</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
@@ -384,8 +385,6 @@
|
||||
</div>
|
||||
<field name="credit_note"/>
|
||||
</group>
|
||||
<separator string="Payslips"/>
|
||||
<field name="slip_ids"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -346,14 +346,6 @@
|
||||
<separator string="Children Definition"/>
|
||||
<field name="child_ids"/>
|
||||
</page>
|
||||
<page name="inputs" string="Inputs">
|
||||
<field name="input_ids" mode="tree">
|
||||
<tree string="Input Data" editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page name="description" string="Description">
|
||||
<field name="note"/>
|
||||
</page>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="report_payslipdetails">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="o">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Pay Slip</h2>
|
||||
<p t-field="o.name"/>
|
||||
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<td><strong>Name</strong></td>
|
||||
<td><span t-field="o.employee_id"/></td>
|
||||
<td><strong>Designation</strong></td>
|
||||
<td><span t-field="o.employee_id.job_id"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Address</strong></td>
|
||||
<td colspan="3">
|
||||
<div t-field="o.employee_id.address_home_id"
|
||||
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True, "phone_icons": True}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email</strong></td>
|
||||
<td><span t-field="o.employee_id.work_email"/></td>
|
||||
<td><strong>Identification No</strong></td>
|
||||
<td><span t-field="o.employee_id.identification_id"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Reference</strong></td>
|
||||
<td><span t-field="o.number"/></td>
|
||||
<td><strong>Bank Account</strong></td>
|
||||
<td><span t-field="o.employee_id.bank_account_id"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Date From</strong></td>
|
||||
<td><span t-field="o.date_from"/></td>
|
||||
<td><strong>Date To</strong></td>
|
||||
<td><span t-field="o.date_to"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Details by Salary Rule Category</h3>
|
||||
<table class="table table-sm mb32">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Salary Rule Category</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="get_details_by_rule_category.get(o.id, [])" t-as="h">
|
||||
<td>
|
||||
<span t-esc="h['code']"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="'..'*h['level']"/><span t-esc="h['rule_category']"/>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<span t-esc="h['total']"
|
||||
t-esc-options='{"widget": "monetary", "display_currency": o.company_id.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Payslip Lines by Contribution Register</h3>
|
||||
<table class="table table-sm mt32">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Quantity/rate</th>
|
||||
<th>Amount</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="get_lines_by_contribution_register.get(o.id, [])" t-as="p">
|
||||
<td><span t-esc="p.get('code', '')"/></td>
|
||||
<td><span t-esc="p.get('name', '')"/></td>
|
||||
<td><span t-esc="p.get('quantity', '')"/></td>
|
||||
<td><span t-esc="p.get('amount', 0)"/></td>
|
||||
<td class="text-right">
|
||||
<span t-esc="p.get('total', 0)"
|
||||
t-esc-options='{"widget": "monetary", "display_currency": o.company_id.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-right"><strong>Authorized signature</strong></p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Calendar View -->
|
||||
<record id="payroll_resource_calendar_view_form" model="ir.ui.view">
|
||||
<field name="name">payroll.resource.calendar.view.form</field>
|
||||
<field name="model">resource.calendar</field>
|
||||
<field name="inherit_id" ref="resource.resource_calendar_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='attendance_ids']" position="replace">
|
||||
<field name="normal_attendance_ids"/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Employees Working Hours">
|
||||
<field name="extra_attendance_ids">
|
||||
<tree string="Working Time" editable="top">
|
||||
<field name="resource_id" string="Employee"/>
|
||||
<field name="name"/>
|
||||
<field name="dayofweek"/>
|
||||
<field name="hour_from" widget="float_time"/>
|
||||
<field name="hour_to" widget="float_time"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="day_period"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import hr_payroll_payslips_by_employees
|
||||
from . import hr_payroll_contribution_register_report
|
||||
from . import hr_benefit_employee
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class HrPayslipEmployees(models.TransientModel):
|
||||
_name = 'hr.benefit.employees'
|
||||
_description = 'Generate benefits for all selected employees'
|
||||
|
||||
def _get_domain(self):
|
||||
return [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)]
|
||||
|
||||
employee_ids = fields.Many2many('hr.employee', string='Employees', domain=lambda self: self._get_domain())
|
||||
|
||||
|
||||
def generate_benefit(self):
|
||||
if not self.employee_ids:
|
||||
raise UserError(_("You must select employee(s) to generate benefits."))
|
||||
date_start = self.env.context.get('start_benefits')
|
||||
date_stop = self.env.context.get('stop_benefits')
|
||||
|
||||
date_start = fields.Datetime.from_string(date_start)
|
||||
date_stop = fields.Datetime.from_string(date_stop)
|
||||
self.employee_ids.generate_benefit(date_start, date_stop)
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_hr_benefit_by_employees" model="ir.ui.view">
|
||||
<field name="name">hr_benefit_employees</field>
|
||||
<field name="model">hr.benefit.employees</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Benefits by Employees">
|
||||
<header>
|
||||
<button icon="fa-cogs" string="Generate" name="generate_benefit" type="object" class="oe_highlight" />
|
||||
</header>
|
||||
<group>
|
||||
<span colspan="4" nolabel="1">This wizard will generate benefits for all selected employee(s) based on attendances.</span>
|
||||
</group>
|
||||
<group colspan="4" >
|
||||
<separator string="Employees" colspan="4"/>
|
||||
<newline/>
|
||||
<field name="employee_ids" nolabel="1"/>
|
||||
</group>
|
||||
<footer></footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -3,45 +3,63 @@
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class HrPayslipEmployees(models.TransientModel):
|
||||
_name = 'hr.payslip.employees'
|
||||
_description = 'Generate payslips for all selected employees'
|
||||
|
||||
employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees',
|
||||
domain=lambda self: self._get_available_contracts_domain())
|
||||
|
||||
def _get_available_contracts_domain(self):
|
||||
return [('contract_ids.state', 'in', ('open', 'pending'))]
|
||||
return [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)]
|
||||
|
||||
def _get_employees(self):
|
||||
return self.env['hr.employee'].search(self._get_available_contracts_domain())
|
||||
|
||||
employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees',
|
||||
domain=lambda self: self._get_available_contracts_domain(),
|
||||
default=lambda self: self._get_employees(), required=True)
|
||||
|
||||
@api.multi
|
||||
def compute_sheet(self):
|
||||
payslips = self.env['hr.payslip']
|
||||
[data] = self.read()
|
||||
active_id = self.env.context.get('active_id')
|
||||
if active_id:
|
||||
[run_data] = self.env['hr.payslip.run'].browse(active_id).read(['date_start', 'date_end', 'credit_note'])
|
||||
from_date = run_data.get('date_start')
|
||||
to_date = run_data.get('date_end')
|
||||
if not data['employee_ids']:
|
||||
self.ensure_one()
|
||||
if not self.env.context.get('active_id'):
|
||||
from_date = datetime.strptime(self.env.context.get('default_date_start'), '%Y-%m-%d')
|
||||
end_date = datetime.strptime(self.env.context.get('default_date_end'), '%Y-%m-%d')
|
||||
payslip_run = self.env['hr.payslip.run'].create({
|
||||
'name': from_date.strftime('%B %Y'),
|
||||
'date_start': from_date,
|
||||
'date_end': end_date,
|
||||
})
|
||||
else:
|
||||
payslip_run = self.env['hr.payslip.run'].browse(self.env.context.get('active_id'))
|
||||
|
||||
if not self.employee_ids:
|
||||
raise UserError(_("You must select employee(s) to generate payslip(s)."))
|
||||
for employee in self.env['hr.employee'].browse(data['employee_ids']):
|
||||
slip_data = self.env['hr.payslip'].onchange_employee_id(from_date, to_date, employee.id, contract_id=False)
|
||||
res = {
|
||||
|
||||
payslips = self.env['hr.payslip']
|
||||
Payslip = self.env['hr.payslip']
|
||||
for employee in self.employee_ids:
|
||||
values = Payslip.default_get(Payslip.fields_get())
|
||||
values.update({
|
||||
'employee_id': employee.id,
|
||||
'name': slip_data['value'].get('name'),
|
||||
'struct_id': slip_data['value'].get('struct_id'),
|
||||
'contract_id': slip_data['value'].get('contract_id'),
|
||||
'payslip_run_id': active_id,
|
||||
'input_line_ids': [(0, 0, x) for x in slip_data['value'].get('input_line_ids')],
|
||||
'worked_days_line_ids': [(0, 0, x) for x in slip_data['value'].get('worked_days_line_ids')],
|
||||
'date_from': from_date,
|
||||
'date_to': to_date,
|
||||
'credit_note': run_data.get('credit_note'),
|
||||
'company_id': employee.company_id.id,
|
||||
}
|
||||
payslips += self.env['hr.payslip'].create(res)
|
||||
'date_from': payslip_run.date_start,
|
||||
'date_to': payslip_run.date_end,
|
||||
'credit_note': payslip_run.credit_note,
|
||||
'payslip_run_id': payslip_run.id,
|
||||
})
|
||||
payslip = self.env['hr.payslip'].new(values)
|
||||
payslip.onchange_employee()
|
||||
payslip.onchange_contract()
|
||||
values = payslip._convert_to_write(payslip._cache)
|
||||
payslips += Payslip.create(values)
|
||||
payslips.compute_sheet()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
payslip_run.state = 'verify'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.payslip.run',
|
||||
'view_type': 'form',
|
||||
'views': [[False, 'form']],
|
||||
'res_id': payslip_run.id,
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<field name="model">hr.payslip.employees</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Payslips by Employees">
|
||||
<header>
|
||||
<button icon="fa-cogs" string="Generate" name="compute_sheet" type="object" class="oe_highlight" />
|
||||
</header>
|
||||
<group>
|
||||
<span colspan="4" nolabel="1">This wizard will generate payslips for all selected employee(s) based on the dates and credit note specified on Payslips Run.</span>
|
||||
</group>
|
||||
@@ -17,6 +14,10 @@
|
||||
<newline/>
|
||||
<field name="employee_ids" nolabel="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button icon="fa-cogs" string="Generate" name="compute_sheet" type="object" class="oe_highlight"/>
|
||||
<button string="Cancel" class="btn btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -69,7 +69,7 @@ class HrPayslip(models.Model):
|
||||
'journal_id': slip.journal_id.id,
|
||||
'date': date,
|
||||
}
|
||||
for line in slip.details_by_salary_rule_category:
|
||||
for line in slip.line_ids.filtered(lambda line: line.category_id):
|
||||
amount = slip.credit_note and -line.total or line.total
|
||||
if float_is_zero(amount, precision_digits=precision):
|
||||
continue
|
||||
|
||||
@@ -56,7 +56,6 @@ class TestHrPayrollAccount(common.TransactionCase):
|
||||
self.ref('hr_payroll.hr_salary_rule_professionaltax1'),
|
||||
self.ref('hr_payroll.hr_salary_rule_providentfund1'),
|
||||
self.ref('hr_payroll.hr_salary_rule_meal_voucher'),
|
||||
self.ref('hr_payroll.hr_salary_rule_sales_commission')
|
||||
])],
|
||||
})
|
||||
|
||||
@@ -75,23 +74,16 @@ class TestHrPayrollAccount(common.TransactionCase):
|
||||
self.hr_payslip = self.env['hr.payslip'].create({
|
||||
'employee_id': self.hr_employee_john.id,
|
||||
'journal_id': self.ref('hr_payroll_account.expenses_journal'),
|
||||
'name': 'Test Payslip',
|
||||
})
|
||||
|
||||
def test_00_hr_payslip(self):
|
||||
""" checking the process of payslip. """
|
||||
|
||||
date_from = time.strftime('%Y-%m-01')
|
||||
date_to = str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10]
|
||||
res = self.hr_payslip.onchange_employee_id(date_from, date_to, self.hr_employee_john.id)
|
||||
vals = {
|
||||
'struct_id': res['value']['struct_id'],
|
||||
'contract_id': res['value']['contract_id'],
|
||||
'name': res['value']['name'],
|
||||
}
|
||||
vals['worked_days_line_ids'] = [(0, 0, i) for i in res['value']['worked_days_line_ids']]
|
||||
vals['input_line_ids'] = [(0, 0, i) for i in res['value']['input_line_ids']]
|
||||
vals.update({'contract_id': self.hr_contract_john.id})
|
||||
self.hr_payslip.write(vals)
|
||||
self.hr_payslip.date_from = time.strftime('%Y-%m-01')
|
||||
self.hr_payslip.date_to = str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10]
|
||||
self.hr_payslip.onchange_employee()
|
||||
self.hr_payslip.onchange_contract()
|
||||
|
||||
# I assign the amount to Input data.
|
||||
payslip_input = self.env['hr.payslip.input'].search([('payslip_id', '=', self.hr_payslip.id)])
|
||||
|
||||
@@ -8,7 +8,7 @@ class HrPayslipEmployees(models.TransientModel):
|
||||
|
||||
@api.multi
|
||||
def compute_sheet(self):
|
||||
journal_id = False
|
||||
if self.env.context.get('active_id'):
|
||||
journal_id = self.env['hr.payslip.run'].browse(self.env.context.get('active_id')).journal_id.id
|
||||
return super(HrPayslipEmployees, self.with_context(journal_id=journal_id)).compute_sheet()
|
||||
return super(HrPayslipEmployees, self.with_context(journal_id=journal_id)).compute_sheet()
|
||||
return super(HrPayslipEmployees, self).compute_sheet()
|
||||
|
||||
@@ -518,65 +518,4 @@ or - (contract.wage * 0.12))</field>
|
||||
<field name="appears_on_payslip" eval="False"/>
|
||||
<field name="note">Both the employees and employer contribute to the fund at the rate of 12% of the basic wages, dearness allowance and retaining allowance, if any, payable to employees per month.</field>
|
||||
</record>
|
||||
|
||||
<!-- Rule Inputs -->
|
||||
|
||||
<record id="hr_rule_input_performance" model="hr.rule.input">
|
||||
<field name="code">PERF</field>
|
||||
<field name="name">Performance of Employee</field>
|
||||
<field name="input_id" ref="hr_salary_rule_performance"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_bonus" model="hr.rule.input">
|
||||
<field name="code">BNS</field>
|
||||
<field name="name">Bonus of Employee</field>
|
||||
<field name="input_id" ref="hr_salary_rule_bonus"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_arrears" model="hr.rule.input">
|
||||
<field name="code">ARS</field>
|
||||
<field name="name">Arrears</field>
|
||||
<field name="input_id" ref="hr_salary_rule_arrears"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_le" model="hr.rule.input">
|
||||
<field name="code">LE</field>
|
||||
<field name="name">Leave Encashment</field>
|
||||
<field name="input_id" ref="hr_salary_rule_le"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_journals" model="hr.rule.input">
|
||||
<field name="code">PJ</field>
|
||||
<field name="name">Periodical Journals</field>
|
||||
<field name="input_id" ref="hr_salary_rule_journals"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_telephone" model="hr.rule.input">
|
||||
<field name="code">TR</field>
|
||||
<field name="name">Telephone Reimbursement</field>
|
||||
<field name="input_id" ref="hr_salary_rule_telephone"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_car" model="hr.rule.input">
|
||||
<field name="code">CEI</field>
|
||||
<field name="name">Car Expenses</field>
|
||||
<field name="input_id" ref="hr_payroll_rule_car"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_internet" model="hr.rule.input">
|
||||
<field name="code">IE</field>
|
||||
<field name="name">Internet Expence</field>
|
||||
<field name="input_id" ref="hr_salary_rule_internet"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_rule_input_leave_avail" model="hr.rule.input">
|
||||
<field name="code">LAI</field>
|
||||
<field name="name">Leave Availed</field>
|
||||
<field name="input_id" ref="hr_payslip_rule_dla"/>
|
||||
</record>
|
||||
<record id="hr_rule_input_medical" model="hr.rule.input">
|
||||
<field name="code">MR</field>
|
||||
<field name="name">Medical Reimbursement</field>
|
||||
<field name="input_id" ref="hr_salary_rule_medical"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import report_payslip_details
|
||||
from . import report_payroll_advice
|
||||
from . import report_hr_salary_employee_bymonth
|
||||
from . import payment_advice_report
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#-*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class PayslipDetailsReportIN(models.AbstractModel):
|
||||
_name = 'report.l10n_in_hr_payroll.report_payslipdetails'
|
||||
_inherit = 'report.hr_payroll.report_payslipdetails'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
payslips = self.env['hr.payslip'].browse(docids)
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'hr.payslip',
|
||||
'docs': payslips,
|
||||
'data': data,
|
||||
'get_details_by_rule_category': self.get_details_by_rule_category(payslips.mapped('details_by_salary_rule_category'))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class TestPaymentAdviceBatch(TestPaymentAdviceBase):
|
||||
payslip_employee.with_context(active_id=payslip_run.id).compute_sheet()
|
||||
|
||||
# I check that the Payslip Batch is in "Draft"
|
||||
self.assertEqual(payslip_run.state, 'draft')
|
||||
self.assertEqual(payslip_run.state, 'verify')
|
||||
|
||||
# Now I close Payslip Batch
|
||||
payslip_run.write({'state': 'close'})
|
||||
|
||||
@@ -197,13 +197,20 @@ class ResourceCalendar(models.Model):
|
||||
# --------------------------------------------------
|
||||
# Computation API
|
||||
# --------------------------------------------------
|
||||
def _attendance_intervals(self, start_dt, end_dt, resource=None):
|
||||
def _attendance_intervals(self, start_dt, end_dt, resource=None, domain=None):
|
||||
""" Return the attendance intervals in the given datetime range.
|
||||
The returned intervals are expressed in the resource's timezone.
|
||||
"""
|
||||
assert start_dt.tzinfo and end_dt.tzinfo
|
||||
combine = datetime.combine
|
||||
|
||||
resource_ids = [resource.id, False] if resource else [False]
|
||||
domain = domain if domain is not None else []
|
||||
domain = domain + [
|
||||
('calendar_id', '=', self.id),
|
||||
('resource_id', 'in', resource_ids),
|
||||
]
|
||||
|
||||
# express all dates and times in the resource's timezone
|
||||
tz = timezone((resource or self).tz)
|
||||
start_dt = start_dt.astimezone(tz)
|
||||
@@ -211,7 +218,7 @@ class ResourceCalendar(models.Model):
|
||||
|
||||
# for each attendance spec, generate the intervals in the date range
|
||||
result = []
|
||||
for attendance in self.attendance_ids:
|
||||
for attendance in self.env['resource.calendar.attendance'].search(domain):
|
||||
start = start_dt.date()
|
||||
if attendance.date_from:
|
||||
start = max(start, attendance.date_from)
|
||||
@@ -404,6 +411,7 @@ class ResourceCalendarAttendance(models.Model):
|
||||
hour_to = fields.Float(string='Work to', required=True)
|
||||
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
|
||||
day_period = fields.Selection([('morning', 'Morning'), ('afternoon', 'Afternoon')], required=True, default='morning')
|
||||
resource_id = fields.Many2one('resource.resource', 'Resource')
|
||||
|
||||
@api.onchange('hour_from', 'hour_to')
|
||||
def _onchange_hours(self):
|
||||
|
||||
@@ -53,6 +53,47 @@ class ResourceMixin(models.AbstractModel):
|
||||
default['resource_calendar_id'] = resource.calendar_id.id
|
||||
return super(ResourceMixin, self).copy_data(default)
|
||||
|
||||
def _get_days_data(self, intervals, day_total):
|
||||
"""
|
||||
helper function to compute duration of `intervals`
|
||||
expressed in days and hours.
|
||||
`day_total` is a dict {date: n_hours} with the number of hours for each day.
|
||||
"""
|
||||
day_hours = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
day_hours[start.date()] += (stop - start).total_seconds() / 3600
|
||||
|
||||
# compute number of days as quarters
|
||||
days = sum(
|
||||
float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR
|
||||
for day in day_hours
|
||||
)
|
||||
return {
|
||||
'days': days,
|
||||
'hours': sum(day_hours.values()),
|
||||
}
|
||||
|
||||
def _get_day_total(self, from_datetime, to_datetime, calendar, resource):
|
||||
"""
|
||||
@return dict with hours of attendance in each day between `from_datetime` and `to_datetime`
|
||||
"""
|
||||
|
||||
# total hours per day: retrieve attendances with one extra day margin,
|
||||
# in order to compute the total hours on the first and last days
|
||||
from_full = from_datetime - timedelta(days=1)
|
||||
to_full = to_datetime + timedelta(days=1)
|
||||
intervals = calendar._attendance_intervals(from_full, to_full, resource)
|
||||
day_total = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
day_total[start.date()] += (stop - start).total_seconds() / 3600
|
||||
return day_total
|
||||
|
||||
def _timezone_datetime(self, time):
|
||||
if not time.tzinfo:
|
||||
time = time.replace(tzinfo=utc)
|
||||
return time
|
||||
|
||||
|
||||
def get_work_days_data(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None):
|
||||
"""
|
||||
By default the resource calendar is used, but it can be
|
||||
@@ -68,38 +109,18 @@ class ResourceMixin(models.AbstractModel):
|
||||
calendar = calendar or self.resource_calendar_id
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
if not from_datetime.tzinfo:
|
||||
from_datetime = from_datetime.replace(tzinfo=utc)
|
||||
if not to_datetime.tzinfo:
|
||||
to_datetime = to_datetime.replace(tzinfo=utc)
|
||||
from_datetime = self._timezone_datetime(from_datetime)
|
||||
to_datetime = self._timezone_datetime(to_datetime)
|
||||
|
||||
# total hours per day: retrieve attendances with one extra day margin,
|
||||
# in order to compute the total hours on the first and last days
|
||||
from_full = from_datetime - timedelta(days=1)
|
||||
to_full = to_datetime + timedelta(days=1)
|
||||
intervals = calendar._attendance_intervals(from_full, to_full, resource)
|
||||
day_total = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
day_total[start.date()] += (stop - start).total_seconds() / 3600
|
||||
day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource)
|
||||
|
||||
# actual hours per day
|
||||
if compute_leaves:
|
||||
intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain)
|
||||
else:
|
||||
intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource)
|
||||
day_hours = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
day_hours[start.date()] += (stop - start).total_seconds() / 3600
|
||||
|
||||
# compute number of days as quarters
|
||||
days = sum(
|
||||
float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR
|
||||
for day in day_hours
|
||||
)
|
||||
return {
|
||||
'days': days,
|
||||
'hours': sum(day_hours.values()),
|
||||
}
|
||||
return self._get_days_data(intervals, day_total)
|
||||
|
||||
def get_leave_days_data(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
"""
|
||||
@@ -116,36 +137,17 @@ class ResourceMixin(models.AbstractModel):
|
||||
calendar = calendar or self.resource_calendar_id
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
if not from_datetime.tzinfo:
|
||||
from_datetime = from_datetime.replace(tzinfo=utc)
|
||||
if not to_datetime.tzinfo:
|
||||
to_datetime = to_datetime.replace(tzinfo=utc)
|
||||
from_datetime = self._timezone_datetime(from_datetime)
|
||||
to_datetime = self._timezone_datetime(to_datetime)
|
||||
|
||||
# total hours per day: retrieve attendances with one extra day margin,
|
||||
# in order to compute the total hours on the first and last days
|
||||
from_full = from_datetime - timedelta(days=1)
|
||||
to_full = to_datetime + timedelta(days=1)
|
||||
intervals = calendar._attendance_intervals(from_full, to_full, resource)
|
||||
day_total = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
day_total[start.date()] += (stop - start).total_seconds() / 3600
|
||||
day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource)
|
||||
|
||||
# compute actual hours per day
|
||||
attendances = calendar._attendance_intervals(from_datetime, to_datetime, resource)
|
||||
leaves = calendar._leave_intervals(from_datetime, to_datetime, resource, domain)
|
||||
day_hours = defaultdict(float)
|
||||
for start, stop, meta in (attendances & leaves):
|
||||
day_hours[start.date()] += (stop - start).total_seconds() / 3600
|
||||
|
||||
# compute number of days as quarters
|
||||
days = sum(
|
||||
float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR
|
||||
for day in day_hours
|
||||
)
|
||||
return {
|
||||
'days': days,
|
||||
'hours': sum(day_hours.values()),
|
||||
}
|
||||
return self._get_days_data(attendances & leaves, day_total)
|
||||
|
||||
|
||||
def list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user