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 @@
+
+
+
+
+
+
+
+
+
+
+
+