diff --git a/l10n_es_hr_siltra/README.rst b/l10n_es_hr_siltra/README.rst new file mode 100644 index 00000000000..eb36be13649 --- /dev/null +++ b/l10n_es_hr_siltra/README.rst @@ -0,0 +1,88 @@ +================================= +Importación de Ficheros de SILTRA +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2624f009477c12315d3773bafe624d5457175c8780f9c638472dce14e5c924f3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--spain-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-spain/tree/18.0/l10n_es_hr_siltra + :alt: OCA/l10n-spain +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-spain-18-0/l10n-spain-18-0-l10n_es_hr_siltra + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-spain&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Este módulo permite la importación de ficheros de SILTRA de forma +automàtica y su procesamiento. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +La Seguridad Social comunica de forma constante las bajas de los +empleados de las empresas. Esta información se recopila en SILTRA y se +subministra mediante el sistema RED Normalmente, estos ficheros los +gestiona la gestoría de turno, pero si se obtienen, se pueden usar para +generar automáticamente todas las ausencias necesarias. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/l10n-spain `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_es_hr_siltra/__init__.py b/l10n_es_hr_siltra/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/l10n_es_hr_siltra/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_es_hr_siltra/__manifest__.py b/l10n_es_hr_siltra/__manifest__.py new file mode 100644 index 00000000000..6e3b60d9d8d --- /dev/null +++ b/l10n_es_hr_siltra/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Importación de Ficheros de SILTRA", + "summary": """Importa los ficheros de SILTRA para generar las + ausencias necesarias de forma automática.""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-spain", + "depends": [ + "hr_holidays", + ], + "data": [ + "views/res_company.xml", + "security/ir.model.access.csv", + "views/hr_siltra.xml", + "views/hr_siltra_item.xml", + ], + "demo": [], +} diff --git a/l10n_es_hr_siltra/models/__init__.py b/l10n_es_hr_siltra/models/__init__.py new file mode 100644 index 00000000000..f6f8262922c --- /dev/null +++ b/l10n_es_hr_siltra/models/__init__.py @@ -0,0 +1,2 @@ +from . import hr_siltra +from . import res_company diff --git a/l10n_es_hr_siltra/models/hr_siltra.py b/l10n_es_hr_siltra/models/hr_siltra.py new file mode 100644 index 00000000000..2090b3f4445 --- /dev/null +++ b/l10n_es_hr_siltra/models/hr_siltra.py @@ -0,0 +1,427 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +from datetime import datetime, timedelta + +from pytz import UTC, timezone + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class HrSiltra(models.Model): + _name = "hr.siltra" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Fichero de SILTRA a procesar" + + name = fields.Char() + filename = fields.Char(copy=False) + file = fields.Binary(copy=False) + file_date = fields.Datetime(copy=False, readonly=True) + state = fields.Selection( + [ + ("draft", "Borrador"), + ("processed", "Procesado"), + ("validated", "Validado"), + ], + default="draft", + copy=False, + tracking=True, + ) + item_ids = fields.One2many("hr.siltra.item", inverse_name="siltra_id") + to_review_item_ids = fields.One2many( + "hr.siltra.item", + domain=[("state", "=", "draft")], + inverse_name="siltra_id", + ) + reviewed_item_ids = fields.One2many( + "hr.siltra.item", + domain=[("state", "=", "validated")], + inverse_name="siltra_id", + ) + nac_items = fields.Integer(compute="_compute_item_counts", string="Births") + dit_items = fields.Integer(compute="_compute_item_counts", string="Absences") + dip_items = fields.Integer(compute="_compute_item_counts", string="Disability") + jub_items = fields.Integer(compute="_compute_item_counts", string="Retirements") + + @api.depends("item_ids.process_type") + def _compute_item_counts(self): + for record in self: + record.nac_items = len( + record.item_ids.filtered(lambda i: i.process_type == "NAC") + ) + record.dit_items = len( + record.item_ids.filtered(lambda i: i.process_type == "DIT") + ) + record.dip_items = len( + record.item_ids.filtered(lambda i: i.process_type == "DIP") + ) + record.jub_items = len( + record.item_ids.filtered(lambda i: i.process_type == "JUB") + ) + + def process(self): + for record in self.filtered(lambda r: r.state == "draft" and r.file): + record._process_file() + + def _process_file(self): + self.ensure_one() + data = base64.b64decode(self.file) + vals = { + "mvals": [], + "context": {}, + "file_date": False, + } + for line in data.decode("latin-1").splitlines(): + line_type = line[0:3] + if hasattr(self, f"_process_{line_type}"): + getattr(self, f"_process_{line_type}")(line, vals) + for single_vals in vals["mvals"]: + single_vals["data"] = ( + "
    " + + ("".join(f"
  • {k}: {v}
  • " for k, v in single_vals.pop("data"))) + + "
" + ) + self.env["hr.siltra.item"].create(vals["mvals"]) + self.file_date = vals["file_date"] + self.state = "processed" + for item in self.item_ids.filtered( + lambda i: i.process_type == "DIT" and i.end_date and i.start_date + ): + item.validate() + if not self.item_ids.filtered(lambda i: i.state == "draft"): + self.validate() + + def validate(self): + if any(self.item_ids.filtered(lambda i: i.state == "draft")): + raise UserError( + _( + "Cannot validate a SILTRA file with unprocessed items. " + "Please process the file first." + ) + ) + self.state = "validated" + + def _process_ETI(self, line, vals): + vals["file_date"] = datetime.strptime(line[29:41], "%Y%m%d%H%M") + pass + + def _process_EMP(self, line, vals): + vals["context"]["company"] = ( + self.env["res.company.seguridad.social"] + .search([("ss_number", "=", line[3:18])], limit=1) + .company_id + ) + + def _process_TRA(self, line, vals): + if vals.get("vals"): + vals["mvals"].append(vals.pop("vals")) + ssnid = line[3:15] + vals["vals"] = { + "ssnid": ssnid, + "company_id": vals["context"]["company"].id, + "siltra_id": self.id, + "employee_id": self.env["hr.employee"] + .search( + [ + ("ssnid", "=", ssnid), + ("company_id", "=", vals["context"]["company"].id), + ], + limit=1, + ) + .id, + "data": [ + ["Número de la Seguridad Social", ssnid], + ["Identificador de persona física (IPF)", line[15:33]], + ["Nacionalidad", line[61:64]], + ], + } + + def _process_AYN(self, line, vals): + vals["vals"]["data"] += [ + ["Apellido 1", line[3:23].strip()], + ["Apellido 2", line[23:43].strip()], + ["Nombre", line[43:63].strip()], + ] + + def _process_DAF(self, line, vals): + if line[11:19] != "00000000": + vals["vals"]["data"].append( + [ + "Fecha Inicio Relación Laboral", + datetime.strptime(line[3:11], "%Y%m%d").date().isoformat(), + ] + ) + if line[11:19] != "00000000": + vals["vals"]["data"].append( + [ + "Fecha Extinción Relación Laboral", + datetime.strptime(line[11:19], "%Y%m%d").date().isoformat(), + ] + ) + + def _process_DIT(self, line, vals): + vals["vals"]["process_type"] = "DIT" + vals["vals"]["data"] += [ + ["Entidad Responsable", line[3:6]], + [ + "Fecha Inicio Incapacidad Temporal", + datetime.strptime(line[6:14], "%Y%m%d").date().isoformat(), + ], + ["Recaída", "Sí" if line[14:15] == "S" else "No"], + ["Días acumulados en el proceso", int(line[31:35])], + ] + if line[15:23] != "00000000": + vals["vals"]["data"].append( + [ + "Fecha Proceso Inicial", + datetime.strptime(line[15:23], "%Y%m%d").date().isoformat(), + ] + ) + if line[23:31] != "00000000": + vals["vals"]["data"].append( + [ + "Fecha Proceso anterior", + datetime.strptime(line[23:31], "%Y%m%d").date().isoformat(), + ] + ) + if line[35:43] != "00000000": + vals["vals"]["data"].append( + [ + "Fecha Proceso IT Inexistente", + datetime.strptime(line[35:43], "%Y%m%d").date().isoformat(), + ] + ) + vals["vals"]["start_date"] = datetime.strptime(line[6:14], "%Y%m%d").date() + if line[62:70] != "00000000": + vals["vals"]["end_date"] = datetime.strptime(line[62:70], "%Y%m%d").date() + + def _process_IT2(self, line, vals): + if line[3:11] != "00000000": + end_date = datetime.strptime(line[3:11], "%Y%m%d").date().isoformat() + vals["vals"]["data"].append( + [ + "Fecha siguiente revisión médica", + end_date, + ] + ) + if not vals["vals"].get("end_date"): + vals["vals"]["end_date"] = end_date + if not vals["vals"].get("last_date"): + vals["vals"]["last_date"] = end_date + + def _process_CIT(self, line, vals): + pass + + def _process_DIP(self, line, vals): + vals["vals"]["process_type"] = "DIP" + vals["vals"]["start_date"] = datetime.strptime(line[11:19], "%Y%m%d").date() + if line[42:50] != "00000000": + vals["vals"]["end_date"] = datetime.strptime(line[42:50], "%Y%m%d").date() + + def _process_JUB(self, line, vals): + vals["vals"]["process_type"] = "JUB" + vals["vals"]["start_date"] = datetime.strptime(line[5:13], "%Y%m%d").date() + vals["vals"]["data"] += [ + ["Tipo de resolución", line[3]], + ["Tipo de jubilación", line[4]], + ] + + def _process_NAC(self, line, vals): + vals["vals"]["process_type"] = "NAC" + vals["vals"]["start_date"] = datetime.strptime(line[3:11], "%Y%m%d").date() + + def _process_DOP(self, line, vals): + vals["vals"]["process_type"] = "DOP" + vals["vals"]["start_date"] = datetime.strptime(line[17:25], "%Y%m%d").date() + vals["vals"]["end_date"] = datetime.strptime(line[25:33], "%Y%m%d").date() + vals["vals"]["partial_percentage"] = int(line[41:46]) / 100 + vals["vals"]["data"] += [ + ["Motivo de Solicitud", self._map_dop_reason(line[3:5])], + ] + + def _map_dop_reason(self, code): + return { + "NC": "Nacimiento y cuidado del menor", + "RE": "Riesgo durante el Embarazo", + "RL": "Riesgo durante la Lactancia", + "CM": "Cuidado del menor", + "CL": "Correspondiente al cuidado del lactante", + }.get(code, code) + + def _process_ETF(self, line, vals): + if vals.get("vals"): + vals["mvals"].append(vals.pop("vals")) + + +class HrSiltraItem(models.Model): + _name = "hr.siltra.item" + _description = "Línea del proceso de un fichero de SILTRA" + + siltra_id = fields.Many2one( + "hr.siltra", + string="Fichero de SILTRA", + required=True, + ondelete="cascade", + readonly=True, + ) + ssnid = fields.Char(string="Número de la Seguridad Social") + company_id = fields.Many2one("res.company", string="Empresa", readonly=True) + employee_id = fields.Many2one("hr.employee", string="Empleado") + partial_percentage = fields.Float() + process_type = fields.Selection( + [ + ("DIT", "Incapacidad Temporal"), + ("DIP", "Incapacidad Permanente"), + ("JUB", "Jubilación"), + ("DOP", "Otras Prestaciones"), + ("NAC", "Nacimiento"), + ], + string="Tipo de proceso", + readonly=True, + ) + start_date = fields.Date() + last_date = fields.Date( + help="""Date of the last review of the process, in the case of DIT. + This is used to set a limit date for the next validation of the process, + as the system expects a new review at least every 30 days""", + ) + end_date = fields.Date() + data = fields.Html(readonly=True) + state = fields.Selection( + [ + ("draft", "Borrador"), + ("validated", "Validado"), + ], + default="draft", + copy=False, + readonly=True, + ) + leave_id = fields.Many2one("hr.leave", readonly=True) + + def validate(self): + for record in self.filtered( + lambda r: r.state == "draft" + and r.employee_id + and r.company_id.siltra_leave_type_id + ): + if record.process_type in ("DIT", "DOP"): + tz = timezone( + record.company_id.resource_calendar_id.tz + or self.env.user.tz + or "UTC" + ) + leave_vals = record._dit_leave_vals() + leave = ( + self.env["hr.leave"] + .with_context(leave_fast_create=True) + .search( + [ + ("employee_id", "=", record.employee_id.id), + ("request_date_from", "=", leave_vals["request_date_from"]), + ("holiday_status_id", "=", leave_vals["holiday_status_id"]), + ("state", "in", ["validate"]), + ], + limit=1, + ) + ) + date_from_tz = ( + tz.localize( + datetime.combine( + leave_vals["request_date_from"], datetime.min.time() + ) + ) + .astimezone(UTC) + .replace(tzinfo=None) + ) + date_to_tz = ( + tz.localize( + datetime.combine( + leave_vals["request_date_to"], datetime.max.time() + ) + ) + .astimezone(UTC) + .replace(tzinfo=None) + ) + # We cancel hourly leaves that overlap with the DIT, as the DIT file + # doesn't specify hours and the employee might have some validated + # hourly leaves that would not be fully covered by the DIT + conflicting_hour_leaves = ( + self.env["hr.leave"] + .with_context( + tracking_disable=True, + mail_activity_automation_skip=True, + leave_fast_create=True, + ) + .search( + [ + ("date_from", "<=", date_to_tz), + ("date_to", ">", date_from_tz), + ("state", "not in", ["cancel", "refuse"]), + ("employee_id", "=", record.employee_id.id), + ("leave_type_request_unit", "in", ["hour", "half_day"]), + ("id", "!=", leave.id), + ] + ) + ) + conflicting_hour_leaves.action_refuse() + conflicting_leaves = ( + self.env["hr.leave"] + .with_context( + tracking_disable=True, + mail_activity_automation_skip=True, + leave_fast_create=True, + ) + .search( + [ + ("date_from", "<=", date_to_tz), + ("date_to", ">", date_from_tz), + ("state", "not in", ["cancel", "refuse"]), + ("employee_id", "=", record.employee_id.id), + ("id", "!=", leave.id), + ] + ) + ) + conflicting_leaves._split_leaves( + leave_vals["request_date_from"], + leave_vals["request_date_to"] + timedelta(days=1), + ) + + if not leave: + leave = ( + self.env["hr.leave"] + .with_context( + leave_fast_create=True, + ) + .create(leave_vals) + ) + leave.action_approve(check_state=False) + else: + leave._remove_resource_leave() + leave.with_context(leave_skip_state_check=True).write(leave_vals) + leave._create_resource_leave() + record.leave_id = leave + record.state = "validated" + + def _dit_leave_vals(self): + self.ensure_one() + end_date = self.end_date + if not end_date and self.last_date: + # If we don't have an end date, we set it to 30 days after + # the last date related to the DIT. We do it this way because that is the + # limit date for the next validation of the process + end_date = self.last_date + timedelta(days=30) + return { + "name": f"Proceso de IT desde {self.start_date} ({self.ssnid})", + "employee_id": self.employee_id.id, + "company_id": self.company_id.id, + "holiday_status_id": self.company_id.siltra_leave_type_id.id, + "request_date_from": self.start_date, + "request_date_to": end_date or self.start_date, + } + + def view_leave(self): + self.ensure_one() + if not self.leave_id: + return + return self.leave_id.get_formview_action() diff --git a/l10n_es_hr_siltra/models/res_company.py b/l10n_es_hr_siltra/models/res_company.py new file mode 100644 index 00000000000..e981382566f --- /dev/null +++ b/l10n_es_hr_siltra/models/res_company.py @@ -0,0 +1,25 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + siltra_leave_type_id = fields.Many2one("hr.leave.type") + ss_number_ids = fields.One2many( + "res.company.seguridad.social", inverse_name="company_id" + ) + + +class ResCompanySeguridadSocial(models.Model): + _name = "res.company.seguridad.social" + _description = "Número de la Seguridad Social de la empresa" + + company_id = fields.Many2one( + "res.company", string="Empresa", required=True, ondelete="cascade" + ) + ss_number = fields.Char( + string="Número de la Seguridad Social", required=True, copy=False + ) diff --git a/l10n_es_hr_siltra/pyproject.toml b/l10n_es_hr_siltra/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/l10n_es_hr_siltra/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/l10n_es_hr_siltra/readme/CONTEXT.md b/l10n_es_hr_siltra/readme/CONTEXT.md new file mode 100644 index 00000000000..fec62deb243 --- /dev/null +++ b/l10n_es_hr_siltra/readme/CONTEXT.md @@ -0,0 +1,3 @@ +La Seguridad Social comunica de forma constante las bajas de los empleados de las empresas. +Esta información se recopila en SILTRA y se subministra mediante el sistema RED +Normalmente, estos ficheros los gestiona la gestoría de turno, pero si se obtienen, se pueden usar para generar automáticamente todas las ausencias necesarias. \ No newline at end of file diff --git a/l10n_es_hr_siltra/readme/CONTRIBUTORS.md b/l10n_es_hr_siltra/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..2c066ba7f42 --- /dev/null +++ b/l10n_es_hr_siltra/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/l10n_es_hr_siltra/readme/DESCRIPTION.md b/l10n_es_hr_siltra/readme/DESCRIPTION.md new file mode 100644 index 00000000000..83a55d65f48 --- /dev/null +++ b/l10n_es_hr_siltra/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Este módulo permite la importación de ficheros de SILTRA de forma automàtica y su procesamiento. diff --git a/l10n_es_hr_siltra/security/ir.model.access.csv b/l10n_es_hr_siltra/security/ir.model.access.csv new file mode 100644 index 00000000000..49477cfdc50 --- /dev/null +++ b/l10n_es_hr_siltra/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_siltra,hr.siltra.manage,model_hr_siltra,hr_holidays.group_hr_holidays_manager,1,1,1,1 +access_hr_siltra_item,hr.siltra.item.manage,model_hr_siltra_item,hr_holidays.group_hr_holidays_manager,1,1,1,1 +access_res_company_seguridad_social,res.company.seguridad.social.access,model_res_company_seguridad_social,hr_holidays.group_hr_holidays_manager,1,0,0,0 +manage_res_company_seguridad_social,res.company.seguridad.social.manage,model_res_company_seguridad_social,base.group_system,1,1,1,1 diff --git a/l10n_es_hr_siltra/static/description/icon.png.oca b/l10n_es_hr_siltra/static/description/icon.png.oca new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/l10n_es_hr_siltra/static/description/icon.png.oca differ diff --git a/l10n_es_hr_siltra/static/description/index.html b/l10n_es_hr_siltra/static/description/index.html new file mode 100644 index 00000000000..40f83c20322 --- /dev/null +++ b/l10n_es_hr_siltra/static/description/index.html @@ -0,0 +1,436 @@ + + + + + +Importación de Ficheros de SILTRA + + + +
+

Importación de Ficheros de SILTRA

+ + +

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

+

Este módulo permite la importación de ficheros de SILTRA de forma +automàtica y su procesamiento.

+

Table of contents

+ +
+

Use Cases / Context

+

La Seguridad Social comunica de forma constante las bajas de los +empleados de las empresas. Esta información se recopila en SILTRA y se +subministra mediante el sistema RED Normalmente, estos ficheros los +gestiona la gestoría de turno, pero si se obtienen, se pueden usar para +generar automáticamente todas las ausencias necesarias.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/l10n-spain project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/l10n_es_hr_siltra/tests/__init__.py b/l10n_es_hr_siltra/tests/__init__.py new file mode 100644 index 00000000000..18e06251d13 --- /dev/null +++ b/l10n_es_hr_siltra/tests/__init__.py @@ -0,0 +1 @@ +from . import test_siltra diff --git a/l10n_es_hr_siltra/tests/sample_files.py b/l10n_es_hr_siltra/tests/sample_files.py new file mode 100644 index 00000000000..068925563e6 --- /dev/null +++ b/l10n_es_hr_siltra/tests/sample_files.py @@ -0,0 +1,17 @@ +SILTRA_HEADER = "ETIFIE41WS90 00353468 {file_date} FIE " +SILTRA_FOOTER = "ETFFIE41WS90 00353468 {file_date} FIENS0001300000105 " +SILTRA_COMPANY = """EMP{company_ssid} +RZS MY COMPANY NAME""" +SILTRA_EMPLOYEE = """TRA{employee_ssid}000000000000000000 +AYNSURNAME01 SURNAME02 MAIN_NAME +DAF2023110100000000 """ +SILTRA_DIT = """DIT333{start_date}{recaida}{process_start_date}{previous_process_end_date}000000000000 00000000000000000{end_date}00 000 +IT2{next_date} 000000000000000000000000000000 0000000000000000 """ # noqa: E501 +SILTRA_CIT = "CIT000000000000000000{next_date} " +SILTRA_DIP = ( + "DIP00000000{date}000000000000000000000000000000000000000000000000N00000000 " +) +SILTRA_JUB = ( + "JUB00{date}000000000000000000000000000000000000000000000000000000000000000 " +) +SILTRA_NAC = "NAC{date} " diff --git a/l10n_es_hr_siltra/tests/test_siltra.py b/l10n_es_hr_siltra/tests/test_siltra.py new file mode 100644 index 00000000000..2c26b488593 --- /dev/null +++ b/l10n_es_hr_siltra/tests/test_siltra.py @@ -0,0 +1,411 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from datetime import date + +from odoo import fields +from odoo.tests.common import TransactionCase + +from . import sample_files + + +class TestSiltra(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + cls.company.siltra_leave_type_id = cls.env["hr.leave.type"].create( + { + "name": "Siltra Leave Type", + "time_type": "leave", + "requires_allocation": "no", + } + ) + cls.extra_leave_type = cls.env["hr.leave.type"].create( + { + "name": "Extra Leave Type", + "time_type": "leave", + "requires_allocation": "no", + "leave_validation_type": "hr", + } + ) + cls.env["res.company.seguridad.social"].create( + { + "company_id": cls.company.id, + "ss_number": "111111111111111", + } + ) + cls.employee_01 = cls.env["hr.employee"].create( + { + "name": "Test Employee 01", + "company_id": cls.company.id, + "ssnid": "111111111111", + } + ) + cls.employee_02 = cls.env["hr.employee"].create( + { + "name": "Test Employee 02", + "company_id": cls.company.id, + "ssnid": "111111111112", + } + ) + cls.employee_03 = cls.env["hr.employee"].create( + { + "name": "Test Employee 03", + "company_id": cls.company.id, + "ssnid": "111111111113", + } + ) + cls.employee_04 = cls.env["hr.employee"].create( + { + "name": "Test Employee 04", + "company_id": cls.company.id, + "ssnid": "111111111114", + } + ) + + def make_file(self, date, items): + file_content = [] + file_content.append( + sample_files.SILTRA_HEADER.format(file_date=date.strftime("%Y%m%d%H%M")) + ) + for item in items: + file_content.append( + sample_files.SILTRA_COMPANY.format( + company_ssid=item["employee_id"] + .company_id.ss_number_ids[0] + .ss_number + ) + ) + file_content.append( + sample_files.SILTRA_EMPLOYEE.format( + employee_ssid=item["employee_id"].ssnid + ) + ) + if item["type"] == "DIT": + file_content.append( + sample_files.SILTRA_DIT.format( + start_date=item["date"].strftime("%Y%m%d"), + recaida="S" if item.get("recaida", False) else "N", + process_start_date=item.get("process_start_date") + and item["process_start_date"].strftime("%Y%m%d") + or "00000000", + previous_process_end_date=item.get("previous_process_end_date") + and item["previous_process_end_date"].strftime("%Y%m%d") + or "00000000", + end_date=item.get("end_date") + and item["end_date"].strftime("%Y%m%d") + or "00000000", + last_review=item.get("last_review") + and item["last_review"].strftime("%Y%m%d") + or "00000000", + next_date=item.get("next_date") + and item["next_date"].strftime("%Y%m%d") + or "00000000", + ) + ) + if not item.get("end_date"): + file_content.append( + sample_files.SILTRA_CIT.format( + next_date=(item["next_date"]).strftime("%Y%m%d") + ) + ) + if item["type"] == "NAC": + file_content.append( + sample_files.SILTRA_NAC.format(date=item["date"].strftime("%Y%m%d")) + ) + if item["type"] == "DIP": + file_content.append( + sample_files.SILTRA_DIP.format(date=item["date"].strftime("%Y%m%d")) + ) + if item["type"] == "JUB": + file_content.append( + sample_files.SILTRA_JUB.format(date=item["date"].strftime("%Y%m%d")) + ) + file_content.append( + sample_files.SILTRA_FOOTER.format(file_date=date.strftime("%Y%m%d%H%M")) + ) + return ("\n".join(file_content)).encode("utf-8") + + def test_CIT_with_birth(self): + siltra_file = self.env["hr.siltra"].create( + { + "name": "Test File", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 26), + }, + { + "employee_id": self.employee_01, + "type": "NAC", + "date": date(2025, 12, 27), + }, + ], + ) + ), + } + ) + siltra_file.process() + self.assertEqual(siltra_file.nac_items, 1) + self.assertEqual(siltra_file.dit_items, 1) + self.assertEqual(siltra_file.dip_items, 0) + self.assertEqual(siltra_file.jub_items, 0) + self.assertEqual(siltra_file.state, "processed") + self.assertEqual(len(siltra_file.item_ids), 2) + leave = self.env["hr.leave"].search([("employee_id", "=", self.employee_01.id)]) + self.assertEqual(len(leave), 1) + self.assertEqual(leave.employee_id, self.employee_01) + self.assertEqual(2, len(siltra_file.item_ids)) + self.assertEqual( + siltra_file.item_ids.filtered(lambda i: i.process_type == "DIT").leave_id, + leave, + ) + self.assertTrue( + siltra_file.item_ids.filtered(lambda i: i.process_type == "NAC") + ) + + def test_chained_files(self): + siltra_file = self.env["hr.siltra"].create( + { + "name": "Test File", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "DIT", + "date": date(2025, 12, 29), + "next_date": date(2026, 1, 26), + }, + ], + ) + ), + } + ) + siltra_file.process() + self.assertEqual(siltra_file.state, "validated") + leave = self.env["hr.leave"].search([("employee_id", "=", self.employee_01.id)]) + self.assertEqual(len(leave), 1) + self.assertEqual(leave.employee_id, self.employee_01) + self.assertEqual(1, len(siltra_file.item_ids)) + self.assertEqual( + siltra_file.item_ids.filtered(lambda i: i.process_type == "DIT").leave_id, + leave, + ) + self.assertEqual(leave.date_to.date(), date(2026, 1, 26)) + siltra_file_02 = self.env["hr.siltra"].create( + { + "name": "Test File 02", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 30), + }, + ], + ) + ), + } + ) + siltra_file_02.process() + self.assertEqual(siltra_file.state, "validated") + self.assertEqual(len(siltra_file_02.item_ids), 1) + self.assertEqual(siltra_file_02.item_ids.process_type, "DIT") + self.assertEqual(siltra_file_02.item_ids.leave_id, leave) + self.assertEqual(leave.date_to.date(), date(2026, 1, 30)) + + def test_DIP(self): + siltra_file = self.env["hr.siltra"].create( + { + "name": "Test File", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "DIP", + "date": date(2025, 12, 29), + }, + ], + ) + ), + } + ) + siltra_file.process() + self.assertEqual(siltra_file.nac_items, 0) + self.assertEqual(siltra_file.dit_items, 0) + self.assertEqual(siltra_file.dip_items, 1) + self.assertEqual(siltra_file.jub_items, 0) + self.assertEqual(len(siltra_file.item_ids), 1) + self.assertEqual(siltra_file.item_ids.process_type, "DIP") + self.assertFalse(siltra_file.item_ids.leave_id) + self.assertEqual(siltra_file.state, "processed") + + def test_JUB(self): + siltra_file = self.env["hr.siltra"].create( + { + "name": "Test File", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "JUB", + "date": date(2025, 12, 29), + }, + ], + ) + ), + } + ) + siltra_file.process() + self.assertEqual(siltra_file.nac_items, 0) + self.assertEqual(siltra_file.dit_items, 0) + self.assertEqual(siltra_file.dip_items, 0) + self.assertEqual(siltra_file.jub_items, 1) + self.assertEqual(len(siltra_file.item_ids), 1) + self.assertEqual(siltra_file.item_ids.process_type, "JUB") + self.assertFalse(siltra_file.item_ids.leave_id) + self.assertEqual(siltra_file.state, "processed") + + def test_changing_other_leaves_approved(self): + leave_01 = self.env["hr.leave"].create( + { + "employee_id": self.employee_01.id, + "holiday_status_id": self.extra_leave_type.id, + "request_date_from": date(2025, 12, 20), + "request_date_to": date(2025, 12, 30), + } + ) + leave_01.action_approve(False) + leave_02 = self.env["hr.leave"].create( + { + "employee_id": self.employee_02.id, + "holiday_status_id": self.extra_leave_type.id, + "request_date_from": date(2025, 12, 20), + "request_date_to": date(2026, 2, 20), + } + ) + leave_02.action_approve(False) + leave_03 = self.env["hr.leave"].create( + { + "employee_id": self.employee_03.id, + "holiday_status_id": self.extra_leave_type.id, + "request_date_from": date(2026, 1, 20), + "request_date_to": date(2026, 2, 28), + } + ) + leave_03.action_approve(False) + leave_04 = self.env["hr.leave"].create( + { + "employee_id": self.employee_04.id, + "holiday_status_id": self.extra_leave_type.id, + "request_date_from": date(2026, 1, 1), + "request_date_to": date(2026, 1, 15), + } + ) + leave_04.action_approve(False) + + self.assertEqual(leave_01.state, "validate") + self.assertEqual(leave_02.state, "validate") + self.assertEqual(leave_03.state, "validate") + self.assertEqual(leave_04.state, "validate") + siltra_file = self.env["hr.siltra"].create( + { + "name": "Test File", + "file": base64.b64encode( + self.make_file( + fields.Datetime.now(), + [ + { + "employee_id": self.employee_01, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 26), + }, + { + "employee_id": self.employee_02, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 26), + }, + { + "employee_id": self.employee_03, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 26), + }, + { + "employee_id": self.employee_04, + "type": "DIT", + "date": date(2025, 12, 29), + "end_date": date(2026, 1, 26), + }, + ], + ) + ), + } + ) + siltra_file.process() + self.assertEqual(siltra_file.nac_items, 0) + self.assertEqual(siltra_file.dit_items, 4) + self.assertEqual(siltra_file.dip_items, 0) + self.assertEqual(siltra_file.jub_items, 0) + leave_01.invalidate_recordset() + leave_02.invalidate_recordset() + leave_03.invalidate_recordset() + leave_04.invalidate_recordset() + self.assertTrue( + self.env["hr.leave"].search( + [ + ("employee_id", "in", [self.employee_01.id]), + ("request_date_from", "=", date(2025, 12, 20)), + ("state", "=", "validate"), + ] + ) + ) + self.assertTrue( + self.env["hr.leave"].search( + [ + ("employee_id", "in", [self.employee_02.id]), + ("request_date_from", "=", date(2025, 12, 20)), + ("state", "=", "validate"), + ] + ) + ) + self.assertTrue( + self.env["hr.leave"].search( + [ + ("employee_id", "in", [self.employee_02.id]), + ("request_date_from", "=", date(2026, 1, 27)), + ("state", "=", "validate"), + ] + ) + ) + self.assertTrue( + self.env["hr.leave"].search( + [ + ("employee_id", "in", [self.employee_03.id]), + ("request_date_from", "=", date(2026, 1, 27)), + ("state", "=", "validate"), + ] + ) + ) + self.assertEqual(leave_01.state, "refuse") + self.assertEqual(leave_02.state, "refuse") + self.assertEqual(leave_03.state, "refuse") + self.assertEqual(leave_04.state, "refuse") diff --git a/l10n_es_hr_siltra/views/hr_siltra.xml b/l10n_es_hr_siltra/views/hr_siltra.xml new file mode 100644 index 00000000000..cc8daee4052 --- /dev/null +++ b/l10n_es_hr_siltra/views/hr_siltra.xml @@ -0,0 +1,108 @@ + + + + + hr.siltra + +
+
+
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + hr.siltra + + + + + + + + + + + + + + hr.siltra + + + + + + + + + + + + + + + Siltra Files + hr.siltra + list,form + [] + {} + + + + Siltra Files + + + + + diff --git a/l10n_es_hr_siltra/views/hr_siltra_item.xml b/l10n_es_hr_siltra/views/hr_siltra_item.xml new file mode 100644 index 00000000000..784c26ca4b9 --- /dev/null +++ b/l10n_es_hr_siltra/views/hr_siltra_item.xml @@ -0,0 +1,33 @@ + + + + + hr.siltra.item + + + + + + + + + +