diff --git a/addons_extensions/consolidated_batch_payslip/static/src/components/pqgrid_batch_payslip/pqgrid_batch_payslip.js b/addons_extensions/consolidated_batch_payslip/static/src/components/pqgrid_batch_payslip/pqgrid_batch_payslip.js index 17882d048..4ecc34a86 100644 --- a/addons_extensions/consolidated_batch_payslip/static/src/components/pqgrid_batch_payslip/pqgrid_batch_payslip.js +++ b/addons_extensions/consolidated_batch_payslip/static/src/components/pqgrid_batch_payslip/pqgrid_batch_payslip.js @@ -1,6 +1,6 @@ /** @odoo-module **/ import {standardWidgetProps} from "@web/views/widgets/standard_widget_props"; -import {Component, onMounted, useRef, useState, onWillStart, onWillUpdateProps} from "@odoo/owl"; +import {Component, onMounted, useRef, useState, onWillStart, onWillUpdateProps, useEffect} from "@odoo/owl"; import {registry} from "@web/core/registry"; import {useService} from "@web/core/utils/hooks"; import {loadJS, loadCSS} from "@web/core/assets"; @@ -35,11 +35,22 @@ export class ConsolidatedPayslipGrid extends Component { } }); + this.currentState = this.props.record.data.state; + onWillUpdateProps(async (nextProps) => { - if (nextProps.record.data.state !== this.props.record.data.state) { + if (nextProps.record.data.state !== this.currentState) { + this.currentState = nextProps.record.data.state; await this.loadGrid(); } }); + + useEffect( + () => { + this.loadGrid(); + }, + () => [this.props.record.data.state] + ); + } async loadDependencies() { @@ -498,7 +509,7 @@ export class ConsolidatedPayslipGrid extends Component { async refreshGrid() { try { - await this.loadGridData(); + await this.loadGrid(); $(this.gridRef.el).pqGrid("refreshDataAndView"); this.notification.add("Data refreshed successfully", {type: 'success'}); } catch (error) { diff --git a/addons_extensions/hr_payroll_extended/__init__.py b/addons_extensions/hr_payroll_extended/__init__.py index 9a7e03ede..9b4296142 100644 --- a/addons_extensions/hr_payroll_extended/__init__.py +++ b/addons_extensions/hr_payroll_extended/__init__.py @@ -1 +1,2 @@ -from . import models \ No newline at end of file +from . import models +from . import wizard diff --git a/addons_extensions/hr_payroll_extended/__manifest__.py b/addons_extensions/hr_payroll_extended/__manifest__.py index d5dc34fc8..626b095ef 100644 --- a/addons_extensions/hr_payroll_extended/__manifest__.py +++ b/addons_extensions/hr_payroll_extended/__manifest__.py @@ -14,12 +14,16 @@ 'security/ir.model.access.csv', '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_payslip_refund_views.xml', + 'wizard/hr_payslip_refund_wizard_views.xml', ], 'assets': { 'web.assets_backend': [ 'hr_payroll_extended/static/src/js/consolidated_payslip_grid_patch.js', + 'hr_payroll_extended/static/src/js/payslip_line_one2many_patch.js', ], }, } diff --git a/addons_extensions/hr_payroll_extended/models/__init__.py b/addons_extensions/hr_payroll_extended/models/__init__.py index b56ed82c1..822a1e351 100644 --- a/addons_extensions/hr_payroll_extended/models/__init__.py +++ b/addons_extensions/hr_payroll_extended/models/__init__.py @@ -1,3 +1,8 @@ from . import hr_work_entry from . import hr_salary_advance from . import hr_payslip_employees +from . import hr_payslip +from . import hr_payslip_run +from . import hr_leave +from . import hr_payslip_refund + diff --git a/addons_extensions/hr_payroll_extended/models/hr_leave.py b/addons_extensions/hr_payroll_extended/models/hr_leave.py new file mode 100644 index 000000000..1a4aadb24 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/models/hr_leave.py @@ -0,0 +1,30 @@ +from odoo import models + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + def _refresh_payroll_extended_payslips(self): + payslips = self.env["hr.payslip"].sudo().search([ + ("employee_id", "in", self.mapped("employee_id").ids), + ("state", "in", ("draft", "verify")), + ]) + for leave in self: + impacted = payslips.filtered( + lambda slip: slip.employee_id == leave.employee_id + and leave.date_from + and leave.date_to + and slip.date_from <= leave.date_to.date() + and slip.date_to >= leave.date_from.date() + ) + for payslip in impacted: + payslip.mapped("worked_days_line_ids").unlink() + payslip.mapped("line_ids").unlink() + payslip.write({"edited": False}) + payslip._compute_worked_days_line_ids() + payslip.compute_sheet() + + def action_validate(self, check_state=True): + res = super().action_validate(check_state=check_state) + self._refresh_payroll_extended_payslips() + return res diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip.py b/addons_extensions/hr_payroll_extended/models/hr_payslip.py new file mode 100644 index 000000000..b131381d5 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools.misc import format_date + + +class HrPayslip(models.Model): + _inherit = "hr.payslip" + + @api.model_create_multi + def create(self, vals_list): + payslips = super().create(vals_list) + payslips._check_duplicate_payslip_periods() + return payslips + + def write(self, vals): + res = super().write(vals) + if {"employee_id", "date_from", "date_to", "state"} & vals.keys(): + self._check_duplicate_payslip_periods() + return res + diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip_employees.py b/addons_extensions/hr_payroll_extended/models/hr_payslip_employees.py index da5e41f30..ea5c5c61a 100644 --- a/addons_extensions/hr_payroll_extended/models/hr_payslip_employees.py +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip_employees.py @@ -1,6 +1,7 @@ from dateutil.relativedelta import relativedelta -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError class HrPayslipEmployees(models.TransientModel): @@ -83,3 +84,25 @@ class HrPayslipEmployees(models.TransientModel): "view_mode": "form", "target": "new", } + + def compute_sheet(self): + self.ensure_one() + date_start, date_end = self._get_generation_dates() + if date_start and date_end: + employees = self.with_context(active_test=False).employee_ids + payslip_run = self.env["hr.payslip.run"].browse(self.env.context.get("active_id")).exists() + if payslip_run: + employees -= payslip_run.slip_ids.employee_id + duplicate_payslips = self.env["hr.payslip"].search([ + ("employee_id", "in", employees.ids), + ("state", "not in", ("draft", "cancel")), + ("date_from", "<=", date_end), + ("date_to", ">=", date_start), + ]) + if duplicate_payslips: + employees_with_duplicates = ", ".join(sorted(set(duplicate_payslips.mapped("employee_id.name")))) + raise UserError(_( + "Payslips already exist for the selected period for: %s. Only draft or canceled payslips can be duplicated.", + employees_with_duplicates, + )) + return super().compute_sheet() diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip_refund.py b/addons_extensions/hr_payroll_extended/models/hr_payslip_refund.py new file mode 100644 index 000000000..22a5f80e2 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip_refund.py @@ -0,0 +1,158 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools.misc import format_date + + +class HrPayslip(models.Model): + _inherit = "hr.payslip" + + state = fields.Selection( + selection_add=[ + ("partial_refund", "Partial Refund"), + ("full_refund", "Full Refund"), + ], + ondelete={ + "partial_refund": "set default", + "full_refund": "set default", + }, + ) + original_refund_payslip_id = fields.Many2one( + "hr.payslip", + string="Original Payslip", + copy=False, + index=True, + help="Payslip that this refund belongs to.", + ) + refund_payslip_ids = fields.One2many( + "hr.payslip", + "original_refund_payslip_id", + string="Refund Payslips", + copy=False, + ) + refund_payslip_count = fields.Integer(compute="_compute_refund_details") + partial_refund_amount = fields.Monetary( + string="Refund Amount", + currency_field="currency_id", + copy=False, + help="Amount returned by the employee for this partial refund adjustment.", + ) + refund_history_payslip_ids = fields.Many2many( + "hr.payslip", + compute="_compute_refund_history_payslip_ids", + string="Refund History", + ) + refunded_amount = fields.Monetary( + string="Refunded Amount", + compute="_compute_refund_details", + currency_field="currency_id", + help="Total amount refunded against this payslip.", + ) + refundable_amount = fields.Monetary( + string="Refundable Amount", + compute="_compute_refund_details", + currency_field="currency_id", + help="Remaining amount available for refund.", + ) + + @api.depends( + "net_wage", + "state", + "partial_refund_amount", + "refund_payslip_ids.state", + "refund_payslip_ids.partial_refund_amount", + ) + def _compute_refund_details(self): + for payslip in self: + reference = payslip.original_refund_payslip_id or payslip + refund_slips = reference.refund_payslip_ids.filtered(lambda slip: slip.state != "cancel") + refunded_amount = sum(refund_slips.mapped("partial_refund_amount")) + total_amount = abs(payslip.net_wage) + if reference.state == "full_refund": + refunded_amount = max(refunded_amount, abs(reference.net_wage)) + if payslip.original_refund_payslip_id: + total_amount = abs(reference.net_wage) + payslip.refund_payslip_count = len(refund_slips) if payslip == reference else 0 + payslip.refunded_amount = refunded_amount + payslip.refundable_amount = max(total_amount - refunded_amount, 0.0) + + @api.depends("employee_id", "date_from", "date_to", "original_refund_payslip_id") + def _compute_refund_history_payslip_ids(self): + for payslip in self: + reference = payslip.original_refund_payslip_id or payslip + if not reference.employee_id or not reference.date_from or not reference.date_to: + payslip.refund_history_payslip_ids = False + continue + payslip.refund_history_payslip_ids = self.search([ + ("id", "!=", payslip.id), + ("employee_id", "=", reference.employee_id.id), + ("state", "!=", "cancel"), + ("date_from", "<=", reference.date_to), + ("date_to", ">=", reference.date_from), + "|", + ("credit_note", "=", True), + ("original_refund_payslip_id", "!=", False), + ]) + + def _check_duplicate_payslip_periods(self): + protected_slips = self.filtered( + lambda slip: ( + slip.employee_id + and slip.date_from + and slip.date_to + and slip.state not in ("draft", "cancel", "full_refund", "partial_refund") + and not slip.credit_note + and not slip.original_refund_payslip_id + ) + ) + for slip in protected_slips: + duplicate = self.search([ + ("id", "!=", slip.id), + ("employee_id", "=", slip.employee_id.id), + ("state", "not in", ("draft", "cancel", "full_refund", "partial_refund")), + ("credit_note", "=", False), + ("date_from", "<=", slip.date_to), + ("date_to", ">=", slip.date_from), + ], limit=1) + if duplicate: + raise ValidationError(_( + "A non-draft payslip already exists for %(employee)s in the selected period (%(date_from)s - %(date_to)s). " + "Cancel or reset the duplicate payslip to draft before confirming another one.", + employee=slip.employee_id.name, + date_from=format_date(self.env, slip.date_from), + date_to=format_date(self.env, slip.date_to), + )) + + def refund_sheet(self): + self.ensure_one() + if self.credit_note or self.original_refund_payslip_id: + raise ValidationError(_("Refunds can only be created from the original payslip.")) + if self.state not in ("done", "paid", "partial_refund"): + raise ValidationError(_("Only done, paid, or partially refunded payslips can be refunded.")) + return { + "name": _("Refund Payslip"), + "type": "ir.actions.act_window", + "res_model": "hr.payslip.refund.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_payslip_id": self.id, + "default_refund_amount": self.refundable_amount or abs(self.net_wage), + }, + } + + def _is_invalid(self): + self.ensure_one() + if self.state in ("partial_refund", "full_refund"): + return False + return super()._is_invalid() + + def action_view_refund_payslips(self): + self.ensure_one() + return { + "name": _("Refund Payslips"), + "type": "ir.actions.act_window", + "res_model": "hr.payslip", + "view_mode": "list,form", + "domain": [("id", "in", self.refund_payslip_ids.ids)], + "context": {"default_original_refund_payslip_id": self.id}, + } diff --git a/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py b/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py new file mode 100644 index 000000000..939f67e10 --- /dev/null +++ b/addons_extensions/hr_payroll_extended/models/hr_payslip_run.py @@ -0,0 +1,18 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, models + + +class HrPayslipRun(models.Model): + _inherit = "hr.payslip.run" + + @api.onchange("date_start") + def _onchange_date_start_extended(self): + for payslip_run in self: + if not payslip_run.date_start: + continue + if payslip_run.date_start.day == 1: + payslip_run.date_end = payslip_run.date_start + relativedelta(day=31) + else: + payslip_run.date_end = payslip_run.date_start + relativedelta(months=1) + 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 25d262472..7f659bf8b 100644 --- a/addons_extensions/hr_payroll_extended/security/ir.model.access.csv +++ b/addons_extensions/hr_payroll_extended/security/ir.model.access.csv @@ -1,3 +1,4 @@ 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 diff --git a/addons_extensions/hr_payroll_extended/static/src/js/consolidated_payslip_grid_patch.js b/addons_extensions/hr_payroll_extended/static/src/js/consolidated_payslip_grid_patch.js index f9fa6840b..a70fdbcf6 100644 --- a/addons_extensions/hr_payroll_extended/static/src/js/consolidated_payslip_grid_patch.js +++ b/addons_extensions/hr_payroll_extended/static/src/js/consolidated_payslip_grid_patch.js @@ -77,6 +77,37 @@ patch(ConsolidatedPayslipGrid.prototype, { this.showNotification("Data refreshed successfully"); }, + async getColumns() { + const columns = await super.getColumns(...arguments); + const component = this; + const editColumn = columns.find((column) => column.title === "Edit"); + if (editColumn) { + editColumn.postRender = function (ui) { + const grid = this; + const $cell = grid.getCell(ui); + $cell.find(".row-btn-edit") + .button({ icons: { primary: "ui-icon-pencil" } }) + .off("click.hr_payroll_extended") + .on("click.hr_payroll_extended", async function (evt) { + evt.preventDefault(); + evt.stopImmediatePropagation(); + const action = await component.orm.call( + "hr.payslip", + "action_edit_payslip_lines", + [ui.rowData.id] + ); + action.views = [[false, "form"]]; + await component.action.doAction(action, { + onClose: async () => { + await component.refreshGrid(); + }, + }); + }); + }; + } + return columns; + }, + async saveChanges() { const grid = this._getGridInstance(); const updatedData = grid.option("dataModel.data"); diff --git a/addons_extensions/hr_payroll_extended/static/src/js/payslip_line_one2many_patch.js b/addons_extensions/hr_payroll_extended/static/src/js/payslip_line_one2many_patch.js new file mode 100644 index 000000000..b2e39c1dc --- /dev/null +++ b/addons_extensions/hr_payroll_extended/static/src/js/payslip_line_one2many_patch.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { WorkedDaysField } from "@hr_payroll/js/payslip_line_one2many"; + +const fieldGetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(WorkedDaysField.prototype), + "fieldComponentProps" +).get; + +Object.defineProperty(WorkedDaysField.prototype, "fieldComponentProps", { + get() { + const props = fieldGetter.call(this); + const record = this.props.record; + if (!record.isExtendedWorkedDaysField) { + record.isExtendedWorkedDaysField = true; + const oldUpdate = record.update.bind(record); + record.update = async (changes) => { + if ("amount" in changes || "quantity" in changes || "number_of_days" in changes || "number_of_hours" in changes) { + await oldUpdate(changes); + await record._parentRecord.save(); + const wizardId = record.model.config.resId; + if (wizardId) { + const action = await this.orm.call( + "hr.payroll.edit.payslip.lines.wizard", + "recompute_worked_days_lines", + [wizardId] + ); + if (action) { + await this.actionService.doAction(action); + } + } + } else { + await oldUpdate(changes); + } + }; + } + return props; + }, +}); diff --git a/addons_extensions/hr_payroll_extended/views/hr_payroll_edit_payslip_lines_wizard_views.xml b/addons_extensions/hr_payroll_extended/views/hr_payroll_edit_payslip_lines_wizard_views.xml new file mode 100644 index 000000000..e20bb597e --- /dev/null +++ b/addons_extensions/hr_payroll_extended/views/hr_payroll_edit_payslip_lines_wizard_views.xml @@ -0,0 +1,59 @@ + + + + hr.payroll.edit.payslip.lines.wizard.form.inherit.extended + hr.payroll.edit.payslip.lines.wizard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/hr_payroll_extended/wizard/__init__.py b/addons_extensions/hr_payroll_extended/wizard/__init__.py new file mode 100644 index 000000000..c7322dbba --- /dev/null +++ b/addons_extensions/hr_payroll_extended/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import hr_payroll_edit_payslip_lines_wizard +from . import hr_payslip_refund_wizard diff --git a/addons_extensions/hr_payroll_extended/wizard/hr_payroll_edit_payslip_lines_wizard.py b/addons_extensions/hr_payroll_extended/wizard/hr_payroll_edit_payslip_lines_wizard.py new file mode 100644 index 000000000..6db6d18cc --- /dev/null +++ b/addons_extensions/hr_payroll_extended/wizard/hr_payroll_edit_payslip_lines_wizard.py @@ -0,0 +1,103 @@ +from odoo import Command, api, fields, models, _ + + +class HrPayrollEditPayslipLinesWizard(models.TransientModel): + _inherit = "hr.payroll.edit.payslip.lines.wizard" + + period_leave_ids = fields.Many2many("hr.leave", compute="_compute_period_leave_ids", string="Leaves in Period") + + def _reload_wizard_action(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "view_mode": "form", + "res_id": self.id, + "views": [(False, "form")], + "target": "new", + } + + @api.depends("payslip_id.employee_id", "payslip_id.date_from", "payslip_id.date_to") + def _compute_period_leave_ids(self): + for wizard in self: + if not wizard.payslip_id.employee_id or not wizard.payslip_id.date_from or not wizard.payslip_id.date_to: + wizard.period_leave_ids = False + continue + date_from = fields.Datetime.to_datetime(wizard.payslip_id.date_from) + date_to = fields.Datetime.to_datetime(wizard.payslip_id.date_to).replace(hour=23, minute=59, second=59) + wizard.period_leave_ids = self.env["hr.leave"].sudo().search([ + ("employee_id", "=", wizard.payslip_id.employee_id.id), + ("state", "in", ("confirm", "validate1", "validate")), + ("date_from", "<=", date_to), + ("date_to", ">=", date_from), + ]) + + def action_open_leave_application(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Apply Leave"), + "res_model": "hr.leave", + "view_mode": "form", + "target": "new", + "context": { + "default_employee_id": self.payslip_id.employee_id.id, + "default_request_date_from": self.payslip_id.date_from, + "default_request_date_to": self.payslip_id.date_to, + "default_private_name": _("Added from payslip %s", self.payslip_id.display_name), + "hr_payroll_extended_refresh_payslip_id": self.payslip_id.id, + }, + } + + def _compute_worked_days_amounts(self): + self.ensure_one() + payslip = self.payslip_id + hours_per_day = payslip._get_worked_day_lines_hours_per_day() + total_hours = sum(self.worked_days_line_ids.mapped("number_of_hours")) + days_count = float((payslip.date_to - payslip.date_from).days + 1) + unpaid_type_ids = set(payslip.struct_id.unpaid_work_entry_type_ids.ids) + + for line in self.worked_days_line_ids: + if hours_per_day and line.number_of_days and not line.number_of_hours: + line.number_of_hours = line.number_of_days * hours_per_day + elif hours_per_day and line.number_of_days: + line.number_of_hours = line.number_of_days * hours_per_day + + is_paid = line.work_entry_type_id.id not in unpaid_type_ids + if not payslip.contract_id or line.code == "OUT": + line.amount = 0 + elif payslip.wage_type == "hourly": + line.amount = payslip.contract_id.hourly_wage * line.number_of_hours if is_paid else 0 + elif line.work_entry_type_id.is_leave: + daily_wage = payslip.contract_id.contract_wage / days_count if days_count else 0 + days = round(line.number_of_hours / hours_per_day, 5) if hours_per_day else line.number_of_days + line.amount = daily_wage * days if is_paid else -(daily_wage * days) + else: + days_to_remove = round((total_hours - line.number_of_hours) / hours_per_day, 5) if hours_per_day else 0 + daily_wage = payslip.contract_id.contract_wage / days_count if days_count else 0 + line.amount = payslip.contract_id.contract_wage - (daily_wage * (days_to_remove if days_to_remove > 0 else 1)) if is_paid else 0 + + def _recompute_salary_lines_from_worked_days(self): + self.ensure_one() + localdict = self.payslip_id._get_localdict() + localdict["worked_days"] = {line.code: line for line in self.worked_days_line_ids if line.code} + payslip = self.payslip_id.with_context(force_payslip_localdict=localdict) + self.line_ids = [Command.clear()] + [Command.create(line) for line in payslip._get_payslip_lines()] + + def recompute_worked_days_lines(self): + self.ensure_one() + self._compute_worked_days_amounts() + self._recompute_salary_lines_from_worked_days() + return self._reload_wizard_action() + + +class HrPayrollEditPayslipWorkedDaysLine(models.TransientModel): + _inherit = "hr.payroll.edit.payslip.worked.days.line" + + @api.onchange("number_of_days") + def _onchange_number_of_days_extended(self): + for line in self: + payslip = line.edit_payslip_lines_wizard_id.payslip_id + if payslip and line.number_of_days: + line.number_of_hours = line.number_of_days * payslip._get_worked_day_lines_hours_per_day() + diff --git a/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard.py b/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard.py new file mode 100644 index 000000000..9f8036b9b --- /dev/null +++ b/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard.py @@ -0,0 +1,160 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class HrPayslipRefundWizard(models.TransientModel): + _name = "hr.payslip.refund.wizard" + _description = "Payslip Refund" + + payslip_id = fields.Many2one("hr.payslip", string="Payslip", required=True, readonly=True) + refund_type = fields.Selection( + [ + ("full", "Full Refund"), + ("partial", "Partial Refund"), + ], + string="Refund Type", + required=True, + default="full", + ) + refund_amount = fields.Monetary( + string="Refund Amount", + required=True, + currency_field="currency_id", + ) + currency_id = fields.Many2one(related="payslip_id.currency_id") + original_amount = fields.Monetary( + string="Original Net Amount", + related="payslip_id.net_wage", + currency_field="currency_id", + readonly=True, + ) + already_refunded_amount = fields.Monetary( + string="Already Refunded", + related="payslip_id.refunded_amount", + currency_field="currency_id", + readonly=True, + ) + refundable_amount = fields.Monetary( + string="Available to Refund", + related="payslip_id.refundable_amount", + currency_field="currency_id", + readonly=True, + ) + previous_refund_ids = fields.Many2many( + "hr.payslip", + compute="_compute_previous_refund_ids", + string="Previous Refunds", + ) + reason = fields.Text(string="Reason") + + @api.depends("payslip_id", "payslip_id.date_from", "payslip_id.date_to", "payslip_id.employee_id") + def _compute_previous_refund_ids(self): + for wizard in self: + if not wizard.payslip_id: + wizard.previous_refund_ids = False + continue + linked_refunds = self.env["hr.payslip"].search([ + ("id", "!=", wizard.payslip_id.id), + ("employee_id", "=", wizard.payslip_id.employee_id.id), + ("state", "!=", "cancel"), + ("original_refund_payslip_id", "=", wizard.payslip_id.id), + ]) + legacy_refunds = self.env["hr.payslip"].search([ + ("id", "!=", wizard.payslip_id.id), + ("employee_id", "=", wizard.payslip_id.employee_id.id), + ("state", "!=", "cancel"), + ("credit_note", "=", True), + ("date_from", "<=", wizard.payslip_id.date_to), + ("date_to", ">=", wizard.payslip_id.date_from), + ]) + wizard.previous_refund_ids = linked_refunds | legacy_refunds + + @api.onchange("refund_type", "payslip_id") + def _onchange_refund_type(self): + for wizard in self: + if not wizard.payslip_id: + continue + if wizard.refund_type == "full": + wizard.refund_amount = wizard.payslip_id.refundable_amount or abs(wizard.payslip_id.net_wage) + elif not wizard.refund_amount: + wizard.refund_amount = wizard.payslip_id.refundable_amount + + def _validate_refund(self): + self.ensure_one() + payslip = self.payslip_id + if payslip.credit_note or payslip.original_refund_payslip_id: + raise ValidationError(_("Refunds can only be created from the original payslip.")) + if payslip.state not in ("done", "paid", "partial_refund"): + raise ValidationError(_("Only done, paid, or partially refunded payslips can be refunded.")) + if self.refund_amount <= 0: + raise ValidationError(_("Refund amount must be greater than zero.")) + + precision = payslip.currency_id.rounding if payslip.currency_id else 0.01 + if self.refund_type == "full" and not float_is_zero( + self.refund_amount - payslip.refundable_amount, + precision_rounding=precision, + ): + raise ValidationError(_("A full refund must refund the complete remaining amount.")) + if float_compare(self.refund_amount, payslip.refundable_amount, precision_rounding=precision) > 0: + raise ValidationError(_("Refund amount cannot be greater than the available refundable amount.")) + + def _scale_adjusted_payslip_lines(self, adjusted_payslip, ratio): + adjusted_payslip.ensure_one() + for worked_day in adjusted_payslip.worked_days_line_ids: + worked_day.write({ + "amount": worked_day.amount * ratio, + }) + for line in adjusted_payslip.input_line_ids: + line.write({"amount": line.amount * ratio}) + for line in adjusted_payslip.line_ids: + line.write({ + "amount": line.amount * ratio, + "total": line.total * ratio, + }) + + def action_confirm_refund(self): + self.ensure_one() + self._validate_refund() + + payslip = self.payslip_id + if self.refund_type == "full": + note = self.reason or _("Full refund created.") + payslip.refund_payslip_ids.filtered(lambda slip: slip.state == "paid").write({"state": "partial_refund"}) + payslip.write({ + "state": "full_refund", + "note": "%s\n%s" % (payslip.note or "", note), + }) + return {"type": "ir.actions.act_window_close"} + + original_net = abs(payslip.net_wage) + if not original_net: + raise ValidationError(_("Cannot create a partial refund for a payslip with zero net amount.")) + + adjusted_net = payslip.refundable_amount - self.refund_amount + precision = payslip.currency_id.rounding if payslip.currency_id else 0.01 + if float_compare(adjusted_net, 0.0, precision_rounding=precision) <= 0: + raise ValidationError(_("Use Full Refund when no amount should remain on the payslip.")) + ratio = adjusted_net / original_net + payslip.refund_payslip_ids.filtered(lambda slip: slip.state == "paid").write({"state": "partial_refund"}) + adjusted_payslip = payslip.copy({ + "credit_note": False, + "original_refund_payslip_id": payslip.id, + "partial_refund_amount": self.refund_amount, + "name": _("Adjusted Payslip: %(payslip)s", payslip=payslip.name), + "edited": True, + "state": "paid", + "paid_date": fields.Date.today(), + "note": self.reason or _("Partial refund of %(amount)s for %(payslip)s", amount=self.refund_amount, payslip=payslip.name), + }) + self._scale_adjusted_payslip_lines(adjusted_payslip, ratio) + payslip.write({"state": "partial_refund"}) + + return { + "name": _("Adjusted Payslip"), + "type": "ir.actions.act_window", + "res_model": "hr.payslip", + "view_mode": "form", + "res_id": adjusted_payslip.id, + "target": "current", + } diff --git a/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard_views.xml b/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard_views.xml new file mode 100644 index 000000000..02ac5a8db --- /dev/null +++ b/addons_extensions/hr_payroll_extended/wizard/hr_payslip_refund_wizard_views.xml @@ -0,0 +1,36 @@ + + + + hr.payslip.refund.wizard.form + hr.payslip.refund.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py index 19760fec8..085fe5274 100644 --- a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py +++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py @@ -583,6 +583,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): "notice_period": parsed_data.get("notice_period"), "applicant_comments": parsed_data.get("summary"), "hr_job_recruitment": self.job_recruitment_id.id, + "company_id": self.env.company.id, } if parsed_data.get("total_experience_years"): applicant_vals["exp_type"] = "experienced" @@ -612,9 +613,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): candidate = False if email_value: - candidate = search_model.search([('id','!=',self.env.context.get('candidate_id')),'|',('email_from','=', email_value),("email_normalized", "=", email_value)], limit=1) + candidate = search_model.search(['|',('company_id','=',False),('company_id','in',[self.env.company.id]),('id','!=',self.env.context.get('candidate_id')),'|',('email_from','=', email_value),("email_normalized", "=", email_value)], limit=1) if not candidate and phone_values: candidate = search_model.search([ + '|', + ('company_id','=',False), + ('company_id','in',[self.env.company.id]), "|","|",('partner_phone',"in",phone_values), ("partner_phone_sanitized", "in", phone_values), ("alternate_phone", "in", phone_values), @@ -1794,8 +1798,8 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): job_request = False if request_id: job_request = job_request_model.search([("recruitment_sequence", "=", request_id)], limit=1) - if not job_request and job_title: - job_request = job_request_model.search([("name", "=ilike", job_title)], limit=1) + # if not job_request and job_title: + # job_request = job_request_model.search([("name", "=ilike", job_title)], limit=1) if job_request: return job_request, "updated" diff --git a/addons_extensions/hr_recruitment_extended/__manifest__.py b/addons_extensions/hr_recruitment_extended/__manifest__.py index 816fae5ea..fbee70830 100644 --- a/addons_extensions/hr_recruitment_extended/__manifest__.py +++ b/addons_extensions/hr_recruitment_extended/__manifest__.py @@ -18,7 +18,7 @@ 'version': '0.1', # any module necessary for this one to work correctly - 'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions','search_view_extension'], + 'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions','search_view_extension','hr_employee_extended','website_payment'], # always loaded 'data': [ diff --git a/addons_extensions/hr_recruitment_extended/views/hr_employee_education_employer_family.xml b/addons_extensions/hr_recruitment_extended/views/hr_employee_education_employer_family.xml index 4cfe126ae..24376f156 100644 --- a/addons_extensions/hr_recruitment_extended/views/hr_employee_education_employer_family.xml +++ b/addons_extensions/hr_recruitment_extended/views/hr_employee_education_employer_family.xml @@ -14,7 +14,7 @@ name="action_validate_personal_details" type="object" class="btn btn-success" - invisible="not employee_id"> + invisible="personal_details_status == 'pending' or not employee_id">
Click here to save & Update Employee Data + invisible="personal_details_status == 'validated' or not employee_id">
Click here to save, Validate and Update into Employee Data