diff --git a/addons_extensions/hr_employee_extended/models/res_config_settings.py b/addons_extensions/hr_employee_extended/models/res_config_settings.py index 85a4f3112..da6467054 100644 --- a/addons_extensions/hr_employee_extended/models/res_config_settings.py +++ b/addons_extensions/hr_employee_extended/models/res_config_settings.py @@ -11,3 +11,23 @@ class ResConfigSettings(models.TransientModel): domain=lambda self: [ ('groups_id', 'in', self.env.ref('hr.group_hr_manager').id)]) + notice_period = fields.Boolean(string='Notice Period', + help='Enable to configure a notice period' + ' for an employee.', + config_parameter='hr_employee_extended.notice_period') + no_of_days = fields.Integer(string='Notice Period Days', + help='Set the number of days for the notice' + ' period.', + config_parameter='hr_employee_extended.no_of_days') + emp_it_manager = fields.Many2one('res.users',config_parameter='hr_employee_extended.emp_it_manager', string='Employee IT Manager', + domain=lambda self: [ + ('groups_id', 'in', self.env.ref('base.group_user').id)]) + + emp_finance_manager = fields.Many2one('res.users',config_parameter='hr_employee_extended.emp_finance_manager', string='Employee Finance Manager', + domain=lambda self: [ + ('groups_id', 'in', self.env.ref('base.group_user').id)]) + + emp_admin = fields.Many2one('res.users',config_parameter='hr_employee_extended.emp_admin', string='Employee Admin', + domain=lambda self: [ + ('groups_id', 'in', self.env.ref('base.group_user').id)]) + diff --git a/addons_extensions/hr_employee_extended/views/res_config_settings.xml b/addons_extensions/hr_employee_extended/views/res_config_settings.xml index f4f6eb402..6b623fb06 100644 --- a/addons_extensions/hr_employee_extended/views/res_config_settings.xml +++ b/addons_extensions/hr_employee_extended/views/res_config_settings.xml @@ -14,6 +14,40 @@ + + + + + + + + + + +
+
+ +
+
+
+
+ diff --git a/addons_extensions/hr_payroll_extended/__manifest__.py b/addons_extensions/hr_payroll_extended/__manifest__.py index 448daaf62..1739a3ad9 100644 --- a/addons_extensions/hr_payroll_extended/__manifest__.py +++ b/addons_extensions/hr_payroll_extended/__manifest__.py @@ -12,13 +12,17 @@ ], 'data': [ 'data/hr_salary_advance_sequence.xml', + 'security/hr_payroll_finance_security.xml', 'security/ir.model.access.csv', + 'report/finance_payment_list_report.xml', 'views/hr_payslip_employees_views.xml', 'views/hr_salary_advance_views.xml', 'views/hr_payroll_edit_payslip_lines_wizard_views.xml', 'views/menus.xml', 'views/hr_work_entry_views.xml', + 'views/hr_payroll_finance_views.xml', 'views/hr_payslip_refund_views.xml', + 'wizard/hr_payslip_finance_reject_wizard_views.xml', 'wizard/hr_payslip_refund_wizard_views.xml', ], 'assets': { diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip.py b/addons_extensions/hr_payroll_extended/models/hr_payslip.py index b131381d5..d5c79e3bd 100644 --- a/addons_extensions/hr_payroll_extended/models/hr_payslip.py +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip.py @@ -1,11 +1,47 @@ +import base64 +import io + from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError -from odoo.tools.misc import format_date +from odoo.exceptions import UserError, ValidationError +from odoo.tools.misc import format_amount, format_date, xlsxwriter class HrPayslip(models.Model): _inherit = "hr.payslip" + @api.model + def default_get(self, default_fields): + """If we're creating a new account through a many2one, there are chances that we typed the account code + instead of its name. In that case, switch both fields values. + """ + context = {} + defaults = super(HrPayslip, self).default_get(default_fields) + + if 'finance_manager_user_id' in default_fields: + finance_manager = self.env['ir.config_parameter'].sudo().get_param('hr_employee_extended.emp_finance_manager') + if finance_manager: + defaults['finance_manager_user_id'] = int(finance_manager) + return defaults + + finance_approval_state = fields.Selection([ + ("not_sent", "Not Sent"), + ("pending", "Pending Finance"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], string="Finance Approval", default="not_sent", copy=False, tracking=True) + finance_manager_user_id = fields.Many2one("res.users", string="Finance Manager", copy=False, tracking=True) + finance_submitted_date = fields.Datetime(string="Sent to Finance On", copy=False, readonly=True) + finance_approved_date = fields.Datetime(string="Finance Approved On", copy=False, readonly=True) + finance_rejected_date = fields.Datetime(string="Finance Rejected On", copy=False, readonly=True) + finance_rejection_reason = fields.Text(string="Finance Rejection Reason", copy=False, readonly=True) + can_finance_approve = fields.Boolean(compute="_compute_can_finance_approve") + + def action_payslip_cancel(self): + if not self.env.user.has_group("hr_payroll_extended.group_payroll_finance_approver") and not self.env.user._is_system() and self.filtered(lambda slip: slip.state == 'done'): + raise UserError(_("Cannot cancel a payslip that is done.")) + self.write({'state': 'cancel'}) + self.mapped('payslip_run_id').action_close() + @api.model_create_multi def create(self, vals_list): payslips = super().create(vals_list) @@ -18,3 +54,196 @@ class HrPayslip(models.Model): self._check_duplicate_payslip_periods() return res + @api.depends("finance_manager_user_id", "finance_approval_state", "state") + def _compute_can_finance_approve(self): + for payslip in self: + payslip.can_finance_approve = ( + payslip.state == "done" + and payslip.finance_approval_state == "pending" + and payslip.finance_manager_user_id == self.env.user + ) + + def _get_config_user(self, param_name, error_message): + user_id = self.env["ir.config_parameter"].sudo().get_param(param_name) + if not user_id: + raise UserError(error_message) + user = self.env["res.users"].sudo().browse(int(user_id)).exists() + if not user: + raise UserError(error_message) + return user + + def _get_finance_manager(self): + user = self._get_config_user( + "hr_employee_extended.emp_finance_manager", + _("Set the finance manager in employee settings."), + ) + finance_group = self.env.ref("hr_payroll_extended.group_payroll_finance_approver", raise_if_not_found=False) + if finance_group and finance_group not in user.groups_id: + user.sudo().write({"groups_id": [(4, finance_group.id)]}) + return user + + def _get_employee_hr(self): + return self._get_config_user( + "hr_employee_extended.emp_hr_id", + _("Set the HR user in employee settings."), + ) + + def _send_payroll_mail(self, user, subject, body): + if not user.email: + raise UserError(_("Please configure an email address for %s.", user.name)) + self.env["mail.mail"].sudo().create({ + "subject": subject, + "body_html": body, + "email_to": user.email, + "auto_delete": True, + }).send() + + def _send_finance_submission_mail(self, finance_manager): + for payslip in self: + subject = _("Payslip pending finance approval: %s", payslip.display_name) + body = _( + "

Dear %(finance)s,

" + "

The payslip for %(employee)s (%(period)s) has been validated by HR and is pending your confirmation.

" + "

Amount: %(amount)s

", + finance=finance_manager.name, + employee=payslip.employee_id.name, + period="%s - %s" % (format_date(self.env, payslip.date_from), format_date(self.env, payslip.date_to)), + amount=format_amount(self.env, payslip.net_wage, payslip.currency_id) if payslip.currency_id else payslip.net_wage, + ) + payslip._send_payroll_mail(finance_manager, subject, body) + + def _send_hr_finance_result_mail(self, approved=True): + hr_user = self._get_employee_hr() + for payslip in self: + if approved: + subject = _("Payslip paid by finance: %s", payslip.display_name) + body = _( + "

Dear %(hr)s,

" + "

The finance manager confirmed payment for %(employee)s (%(period)s).

", + hr=hr_user.name, + employee=payslip.employee_id.name, + period="%s - %s" % (format_date(self.env, payslip.date_from), format_date(self.env, payslip.date_to)), + ) + else: + subject = _("Payslip rejected by finance: %s", payslip.display_name) + body = _( + "

Dear %(hr)s,

" + "

The finance manager rejected the payslip for %(employee)s (%(period)s).

" + "

Reason: %(reason)s

", + hr=hr_user.name, + employee=payslip.employee_id.name, + period="%s - %s" % (format_date(self.env, payslip.date_from), format_date(self.env, payslip.date_to)), + reason=payslip.finance_rejection_reason or "", + ) + payslip._send_payroll_mail(hr_user, subject, body) + + def _mark_pending_finance_approval(self): + finance_manager = self._get_finance_manager() + now = fields.Datetime.now() + slips = self.filtered(lambda slip: slip.state == "done" and not slip.payslip_run_id) + if not slips: + return + slips.write({ + "finance_approval_state": "pending", + "finance_manager_user_id": finance_manager.id, + "finance_submitted_date": now, + "finance_approved_date": False, + "finance_rejected_date": False, + "finance_rejection_reason": False, + }) + slips.mapped("payslip_run_id").write({ + "finance_manager_user_id": finance_manager.id, + "finance_approval_state": "pending", + "finance_submitted_date": now, + "finance_approved_date": False, + "finance_rejected_date": False, + "finance_rejection_reason": False, + }) + slips._send_finance_submission_mail(finance_manager) + + def action_payslip_done(self): + result = super().action_payslip_done() + self._mark_pending_finance_approval() + return result + + def _check_current_user_is_finance_manager(self): + finance_manager = self._get_finance_manager() + if self.env.user != finance_manager and not self.env.user._is_system(): + raise UserError(_("Only the configured finance manager can confirm or reject these payslips.")) + + def action_payslip_paid(self): + self._check_current_user_is_finance_manager() + pending_slips = self.filtered(lambda slip: slip.state != "paid") + if any(slip.finance_approval_state != "pending" for slip in pending_slips): + raise UserError(_("Only payslips pending finance approval can be marked as paid.")) + result = super().action_payslip_paid() + approved_slips = self.filtered(lambda slip: slip.state == "paid") + approved_slips.write({ + "finance_approval_state": "approved", + "finance_approved_date": fields.Datetime.now(), + }) + approved_slips._send_hr_finance_result_mail(approved=True) + return result + + def action_finance_reject_wizard(self): + self.ensure_one() + self._check_current_user_is_finance_manager() + if self.state != "done" or self.finance_approval_state != "pending": + raise UserError(_("Only payslips pending finance approval can be rejected.")) + return { + "type": "ir.actions.act_window", + "name": _("Reject Payslip"), + "res_model": "hr.payslip.finance.reject.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_payslip_ids": [(6, 0, self.ids)]}, + } + + def action_finance_download_xlsx(self): + return self._action_finance_download_xlsx() + + def action_finance_download_pdf(self): + return self.env.ref("hr_payroll_extended.action_report_finance_payment_list").report_action(self) + + def _get_finance_payment_rows(self): + rows = [] + for slip in self: + rows.append({ + "employee": slip.employee_id.name or "", + "bank_account": slip.employee_id.bank_account_id.acc_number or "", + "amount": slip.net_wage, + "currency": slip.currency_id, + }) + return rows + + def _action_finance_download_xlsx(self, filename=None): + if not self: + raise UserError(_("There are no payslips to export.")) + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {"in_memory": True}) + worksheet = workbook.add_worksheet("Payment List") + header_format = workbook.add_format({"bold": True, "bg_color": "#E0E0E0", "align": "center"}) + money_format = workbook.add_format({"num_format": "#,##0.00"}) + headers = [_("Employee Name"), _("Bank Account"), _("Amount")] + worksheet.write_row(0, 0, headers, header_format) + for row_idx, row in enumerate(self._get_finance_payment_rows(), start=1): + worksheet.write(row_idx, 0, row["employee"]) + worksheet.write(row_idx, 1, row["bank_account"]) + worksheet.write_number(row_idx, 2, row["amount"] or 0.0, money_format) + worksheet.set_column(0, 0, 28) + worksheet.set_column(1, 1, 26) + worksheet.set_column(2, 2, 16) + workbook.close() + attachment = self.env["ir.attachment"].sudo().create({ + "name": filename or _("Finance Payment List.xlsx"), + "type": "binary", + "datas": base64.b64encode(output.getvalue()), + "mimetype": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "res_model": self._name, + "res_id": self[:1].id, + }) + return { + "type": "ir.actions.act_url", + "url": "/web/content/%s?download=true" % attachment.id, + "target": "self", + } diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py b/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py index 939f67e10..1fcba9162 100644 --- a/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py @@ -1,11 +1,41 @@ from dateutil.relativedelta import relativedelta -from odoo import api, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError class HrPayslipRun(models.Model): _inherit = "hr.payslip.run" + @api.model + def default_get(self, default_fields): + """If we're creating a new account through a many2one, there are chances that we typed the account code + instead of its name. In that case, switch both fields values. + """ + context = {} + defaults = super(HrPayslipRun, self).default_get(default_fields) + + if 'finance_manager_user_id' in default_fields: + finance_manager = self.env['ir.config_parameter'].sudo().get_param( + 'hr_employee_extended.emp_finance_manager') + + if finance_manager: + defaults['finance_manager_user_id'] = int(finance_manager) + return defaults + + finance_approval_state = fields.Selection([ + ("not_sent", "Not Sent"), + ("pending", "Pending Finance"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], string="Finance Approval", default="not_sent", copy=False, tracking=True) + finance_manager_user_id = fields.Many2one("res.users", string="Finance Manager", copy=False, tracking=True) + finance_submitted_date = fields.Datetime(string="Sent to Finance On", copy=False, readonly=True) + finance_approved_date = fields.Datetime(string="Finance Approved On", copy=False, readonly=True) + finance_rejected_date = fields.Datetime(string="Finance Rejected On", copy=False, readonly=True) + finance_rejection_reason = fields.Text(string="Finance Rejection Reason", copy=False, readonly=True) + can_finance_approve = fields.Boolean(compute="_compute_can_finance_approve") + @api.onchange("date_start") def _onchange_date_start_extended(self): for payslip_run in self: @@ -16,3 +46,81 @@ class HrPayslipRun(models.Model): else: payslip_run.date_end = payslip_run.date_start + relativedelta(months=1) + @api.depends("finance_manager_user_id", "finance_approval_state", "state") + def _compute_can_finance_approve(self): + for payslip_run in self: + payslip_run.can_finance_approve = ( + payslip_run.state == "close" + and payslip_run.finance_approval_state == "pending" + and payslip_run.finance_manager_user_id == self.env.user + ) + + def _send_finance_submission_mail(self, finance_manager): + for payslip_run in self: + subject = _("Payslip batch pending finance approval: %s", payslip_run.name) + body = _( + "

Dear %(finance)s,

" + "

The payslip batch %(batch)s has been validated by HR and is pending your confirmation.

" + "

Payslips: %(count)s

", + finance=finance_manager.name, + batch=payslip_run.name, + count=len(payslip_run.slip_ids), + ) + payslip_run.slip_ids[:1]._send_payroll_mail(finance_manager, subject, body) + + def action_validate(self): + result = super().action_validate() + for payslip_run in self.filtered(lambda run: run.state == "close"): + finance_manager = payslip_run.slip_ids[:1]._get_finance_manager() + now = fields.Datetime.now() + payslip_run.write({ + "finance_approval_state": "pending", + "finance_manager_user_id": finance_manager.id, + "finance_submitted_date": now, + "finance_approved_date": False, + "finance_rejected_date": False, + "finance_rejection_reason": False, + }) + payslip_run.slip_ids.filtered(lambda slip: slip.state == "done").write({ + "finance_approval_state": "pending", + "finance_manager_user_id": finance_manager.id, + "finance_submitted_date": now, + "finance_approved_date": False, + "finance_rejected_date": False, + "finance_rejection_reason": False, + }) + payslip_run._send_finance_submission_mail(finance_manager) + return result + + def action_paid(self): + for payslip_run in self: + if payslip_run.finance_approval_state != "pending": + raise UserError(_("Only batches pending finance approval can be marked as paid.")) + result = super().action_paid() + self.write({ + "finance_approval_state": "approved", + "finance_approved_date": fields.Datetime.now(), + }) + return result + + def action_finance_reject_wizard(self): + self.ensure_one() + self.slip_ids[:1]._check_current_user_is_finance_manager() + if self.state != "close" or self.finance_approval_state != "pending": + raise UserError(_("Only batches pending finance approval can be rejected.")) + return { + "type": "ir.actions.act_window", + "name": _("Reject Payslip Batch"), + "res_model": "hr.payslip.finance.reject.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_payslip_run_id": self.id}, + } + + def action_finance_download_xlsx(self): + self.ensure_one() + return self.slip_ids._action_finance_download_xlsx(filename="%s.xlsx" % (self.name or _("Finance Payment List"))) + + def action_finance_download_pdf(self): + self.ensure_one() + return self.env.ref("hr_payroll_extended.action_report_finance_payment_list").report_action(self.slip_ids) diff --git a/addons_extensions/hr_payroll_extended/report/finance_payment_list_report.xml b/addons_extensions/hr_payroll_extended/report/finance_payment_list_report.xml new file mode 100644 index 000000000..867a901db --- /dev/null +++ b/addons_extensions/hr_payroll_extended/report/finance_payment_list_report.xml @@ -0,0 +1,56 @@ + + + + Finance Payment List + hr.payslip + qweb-pdf + hr_payroll_extended.report_finance_payment_list + hr_payroll_extended.report_finance_payment_list + 'Finance Payment List' + + + + diff --git a/addons_extensions/hr_payroll_extended/security/hr_payroll_finance_security.xml b/addons_extensions/hr_payroll_extended/security/hr_payroll_finance_security.xml new file mode 100644 index 000000000..4b7bfd2d1 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/security/hr_payroll_finance_security.xml @@ -0,0 +1,64 @@ + + + + + Payroll Finance Approver + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/hr_payroll_extended/security/ir.model.access.csv b/addons_extensions/hr_payroll_extended/security/ir.model.access.csv index 7f659bf8b..85758662b 100644 --- a/addons_extensions/hr_payroll_extended/security/ir.model.access.csv +++ b/addons_extensions/hr_payroll_extended/security/ir.model.access.csv @@ -2,3 +2,9 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hr_salary_advance_user,hr.salary.advance.user,model_hr_salary_advance,hr_payroll.group_hr_payroll_user,1,1,1,0 access_hr_salary_advance_manager,hr.salary.advance.manager,model_hr_salary_advance,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_hr_payslip_refund_wizard,hr.payslip.refund.wizard,model_hr_payslip_refund_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_hr_payslip_finance_user,hr.payslip.finance.user,hr_payroll.model_hr_payslip,hr_payroll_extended.group_payroll_finance_approver,1,1,0,0 +access_hr_payslip_run_finance_user,hr.payslip.run.finance.user,hr_payroll.model_hr_payslip_run,hr_payroll_extended.group_payroll_finance_approver,1,1,0,0 +access_hr_payslip_line_finance_user,hr.payslip.line.finance.user,hr_payroll.model_hr_payslip_line,hr_payroll_extended.group_payroll_finance_approver,1,0,0,0 +access_hr_payslip_input_finance_user,hr.payslip.input.finance.user,hr_payroll.model_hr_payslip_input,hr_payroll_extended.group_payroll_finance_approver,1,0,0,0 +access_hr_payslip_worked_days_finance_user,hr.payslip.worked.days.finance.user,hr_payroll.model_hr_payslip_worked_days,hr_payroll_extended.group_payroll_finance_approver,1,0,0,0 +access_hr_payslip_finance_reject_wizard,hr.payslip.finance.reject.wizard,model_hr_payslip_finance_reject_wizard,hr_payroll_extended.group_payroll_finance_approver,1,1,1,0 diff --git a/addons_extensions/hr_payroll_extended/views/hr_payroll_finance_views.xml b/addons_extensions/hr_payroll_extended/views/hr_payroll_finance_views.xml new file mode 100644 index 000000000..e062eeb10 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/views/hr_payroll_finance_views.xml @@ -0,0 +1,117 @@ + + + + hr.payslip.list.finance.inherit + hr.payslip + + + + + + + + + + + hr.payslip.form.finance.inherit + hr.payslip + + + + not can_finance_approve + Confirm Payment + + + 1 + + +