coati-payroll 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of coati-payroll might be problematic. Click here for more details.

Files changed (243) hide show
  1. coati_payroll/__init__.py +415 -0
  2. coati_payroll/app.py +95 -0
  3. coati_payroll/audit_helpers.py +904 -0
  4. coati_payroll/auth.py +123 -0
  5. coati_payroll/cli.py +1318 -0
  6. coati_payroll/config.py +219 -0
  7. coati_payroll/demo_data.py +813 -0
  8. coati_payroll/enums.py +278 -0
  9. coati_payroll/forms.py +1769 -0
  10. coati_payroll/formula_engine/__init__.py +81 -0
  11. coati_payroll/formula_engine/ast/__init__.py +110 -0
  12. coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
  13. coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
  14. coati_payroll/formula_engine/ast/safe_operators.py +131 -0
  15. coati_payroll/formula_engine/ast/type_converter.py +172 -0
  16. coati_payroll/formula_engine/data_sources.py +752 -0
  17. coati_payroll/formula_engine/engine.py +247 -0
  18. coati_payroll/formula_engine/exceptions.py +52 -0
  19. coati_payroll/formula_engine/execution/__init__.py +24 -0
  20. coati_payroll/formula_engine/execution/execution_context.py +52 -0
  21. coati_payroll/formula_engine/execution/step_executor.py +62 -0
  22. coati_payroll/formula_engine/execution/variable_store.py +59 -0
  23. coati_payroll/formula_engine/novelty_codes.py +206 -0
  24. coati_payroll/formula_engine/results/__init__.py +20 -0
  25. coati_payroll/formula_engine/results/execution_result.py +59 -0
  26. coati_payroll/formula_engine/steps/__init__.py +30 -0
  27. coati_payroll/formula_engine/steps/assignment_step.py +71 -0
  28. coati_payroll/formula_engine/steps/base_step.py +48 -0
  29. coati_payroll/formula_engine/steps/calculation_step.py +42 -0
  30. coati_payroll/formula_engine/steps/conditional_step.py +122 -0
  31. coati_payroll/formula_engine/steps/step_factory.py +58 -0
  32. coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
  33. coati_payroll/formula_engine/tables/__init__.py +24 -0
  34. coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
  35. coati_payroll/formula_engine/tables/table_lookup.py +161 -0
  36. coati_payroll/formula_engine/tables/tax_table.py +32 -0
  37. coati_payroll/formula_engine/validation/__init__.py +24 -0
  38. coati_payroll/formula_engine/validation/schema_validator.py +37 -0
  39. coati_payroll/formula_engine/validation/security_validator.py +52 -0
  40. coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
  41. coati_payroll/formula_engine_examples.py +153 -0
  42. coati_payroll/i18n.py +54 -0
  43. coati_payroll/initial_data.py +613 -0
  44. coati_payroll/interes_engine.py +450 -0
  45. coati_payroll/liquidacion_engine/__init__.py +25 -0
  46. coati_payroll/liquidacion_engine/engine.py +267 -0
  47. coati_payroll/locale_config.py +165 -0
  48. coati_payroll/log.py +138 -0
  49. coati_payroll/model.py +2410 -0
  50. coati_payroll/nomina_engine/__init__.py +87 -0
  51. coati_payroll/nomina_engine/calculators/__init__.py +30 -0
  52. coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
  53. coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
  54. coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
  55. coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
  56. coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
  57. coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
  58. coati_payroll/nomina_engine/domain/__init__.py +27 -0
  59. coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
  60. coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
  61. coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
  62. coati_payroll/nomina_engine/engine.py +188 -0
  63. coati_payroll/nomina_engine/processors/__init__.py +28 -0
  64. coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
  65. coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
  66. coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
  67. coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
  68. coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
  69. coati_payroll/nomina_engine/repositories/__init__.py +32 -0
  70. coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
  71. coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
  72. coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
  73. coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
  74. coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
  75. coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
  76. coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
  77. coati_payroll/nomina_engine/results/__init__.py +24 -0
  78. coati_payroll/nomina_engine/results/error_result.py +28 -0
  79. coati_payroll/nomina_engine/results/payroll_result.py +53 -0
  80. coati_payroll/nomina_engine/results/validation_result.py +39 -0
  81. coati_payroll/nomina_engine/services/__init__.py +22 -0
  82. coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
  83. coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
  84. coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
  85. coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
  86. coati_payroll/nomina_engine/validators/__init__.py +31 -0
  87. coati_payroll/nomina_engine/validators/base_validator.py +48 -0
  88. coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
  89. coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
  90. coati_payroll/nomina_engine/validators/period_validator.py +44 -0
  91. coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
  92. coati_payroll/plugin_manager.py +176 -0
  93. coati_payroll/queue/__init__.py +33 -0
  94. coati_payroll/queue/driver.py +127 -0
  95. coati_payroll/queue/drivers/__init__.py +22 -0
  96. coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
  97. coati_payroll/queue/drivers/huey_driver.py +390 -0
  98. coati_payroll/queue/drivers/noop_driver.py +54 -0
  99. coati_payroll/queue/selector.py +121 -0
  100. coati_payroll/queue/tasks.py +764 -0
  101. coati_payroll/rate_limiting.py +83 -0
  102. coati_payroll/rbac.py +183 -0
  103. coati_payroll/report_engine.py +512 -0
  104. coati_payroll/report_export.py +208 -0
  105. coati_payroll/schema_validator.py +167 -0
  106. coati_payroll/security.py +77 -0
  107. coati_payroll/static/styles.css +1044 -0
  108. coati_payroll/system_reports.py +573 -0
  109. coati_payroll/templates/auth/login.html +189 -0
  110. coati_payroll/templates/base.html +283 -0
  111. coati_payroll/templates/index.html +227 -0
  112. coati_payroll/templates/macros.html +146 -0
  113. coati_payroll/templates/modules/calculation_rule/form.html +78 -0
  114. coati_payroll/templates/modules/calculation_rule/index.html +102 -0
  115. coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
  116. coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
  117. coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
  118. coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
  119. coati_payroll/templates/modules/config_calculos/index.html +44 -0
  120. coati_payroll/templates/modules/configuracion/index.html +90 -0
  121. coati_payroll/templates/modules/currency/form.html +47 -0
  122. coati_payroll/templates/modules/currency/index.html +64 -0
  123. coati_payroll/templates/modules/custom_field/form.html +62 -0
  124. coati_payroll/templates/modules/custom_field/index.html +78 -0
  125. coati_payroll/templates/modules/deduccion/form.html +1 -0
  126. coati_payroll/templates/modules/deduccion/index.html +1 -0
  127. coati_payroll/templates/modules/employee/form.html +254 -0
  128. coati_payroll/templates/modules/employee/index.html +76 -0
  129. coati_payroll/templates/modules/empresa/form.html +74 -0
  130. coati_payroll/templates/modules/empresa/index.html +71 -0
  131. coati_payroll/templates/modules/exchange_rate/form.html +47 -0
  132. coati_payroll/templates/modules/exchange_rate/import.html +93 -0
  133. coati_payroll/templates/modules/exchange_rate/index.html +114 -0
  134. coati_payroll/templates/modules/liquidacion/index.html +58 -0
  135. coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
  136. coati_payroll/templates/modules/liquidacion/ver.html +91 -0
  137. coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
  138. coati_payroll/templates/modules/percepcion/form.html +1 -0
  139. coati_payroll/templates/modules/percepcion/index.html +1 -0
  140. coati_payroll/templates/modules/planilla/config.html +190 -0
  141. coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
  142. coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
  143. coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
  144. coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
  145. coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
  146. coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
  147. coati_payroll/templates/modules/planilla/form.html +197 -0
  148. coati_payroll/templates/modules/planilla/index.html +144 -0
  149. coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
  150. coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
  151. coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
  152. coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
  153. coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
  154. coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
  155. coati_payroll/templates/modules/plugins/index.html +71 -0
  156. coati_payroll/templates/modules/prestacion/form.html +1 -0
  157. coati_payroll/templates/modules/prestacion/index.html +1 -0
  158. coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
  159. coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
  160. coati_payroll/templates/modules/prestamo/approve.html +156 -0
  161. coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
  162. coati_payroll/templates/modules/prestamo/detail.html +443 -0
  163. coati_payroll/templates/modules/prestamo/form.html +203 -0
  164. coati_payroll/templates/modules/prestamo/index.html +150 -0
  165. coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
  166. coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
  167. coati_payroll/templates/modules/report/admin_index.html +125 -0
  168. coati_payroll/templates/modules/report/detail.html +129 -0
  169. coati_payroll/templates/modules/report/execute.html +266 -0
  170. coati_payroll/templates/modules/report/index.html +95 -0
  171. coati_payroll/templates/modules/report/permissions.html +64 -0
  172. coati_payroll/templates/modules/settings/index.html +274 -0
  173. coati_payroll/templates/modules/shared/concept_form.html +201 -0
  174. coati_payroll/templates/modules/shared/concept_index.html +145 -0
  175. coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
  176. coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
  177. coati_payroll/templates/modules/user/form.html +65 -0
  178. coati_payroll/templates/modules/user/index.html +76 -0
  179. coati_payroll/templates/modules/user/profile.html +81 -0
  180. coati_payroll/templates/modules/vacation/account_detail.html +149 -0
  181. coati_payroll/templates/modules/vacation/account_form.html +52 -0
  182. coati_payroll/templates/modules/vacation/account_index.html +68 -0
  183. coati_payroll/templates/modules/vacation/dashboard.html +156 -0
  184. coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
  185. coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
  186. coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
  187. coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
  188. coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
  189. coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
  190. coati_payroll/templates/modules/vacation/policy_form.html +152 -0
  191. coati_payroll/templates/modules/vacation/policy_index.html +79 -0
  192. coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
  193. coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
  194. coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
  195. coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
  196. coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
  197. coati_payroll/vacation_service.py +451 -0
  198. coati_payroll/version.py +18 -0
  199. coati_payroll/vistas/__init__.py +64 -0
  200. coati_payroll/vistas/calculation_rule.py +307 -0
  201. coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
  202. coati_payroll/vistas/config_calculos.py +72 -0
  203. coati_payroll/vistas/configuracion.py +87 -0
  204. coati_payroll/vistas/constants.py +17 -0
  205. coati_payroll/vistas/currency.py +112 -0
  206. coati_payroll/vistas/custom_field.py +120 -0
  207. coati_payroll/vistas/employee.py +305 -0
  208. coati_payroll/vistas/empresa.py +153 -0
  209. coati_payroll/vistas/exchange_rate.py +341 -0
  210. coati_payroll/vistas/liquidacion.py +205 -0
  211. coati_payroll/vistas/payroll_concepts.py +580 -0
  212. coati_payroll/vistas/planilla/__init__.py +38 -0
  213. coati_payroll/vistas/planilla/association_routes.py +238 -0
  214. coati_payroll/vistas/planilla/config_routes.py +158 -0
  215. coati_payroll/vistas/planilla/export_routes.py +175 -0
  216. coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
  217. coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
  218. coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
  219. coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
  220. coati_payroll/vistas/planilla/nomina_routes.py +488 -0
  221. coati_payroll/vistas/planilla/novedad_routes.py +227 -0
  222. coati_payroll/vistas/planilla/routes.py +145 -0
  223. coati_payroll/vistas/planilla/services/__init__.py +26 -0
  224. coati_payroll/vistas/planilla/services/export_service.py +687 -0
  225. coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
  226. coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
  227. coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
  228. coati_payroll/vistas/planilla/validators/__init__.py +18 -0
  229. coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
  230. coati_payroll/vistas/plugins.py +45 -0
  231. coati_payroll/vistas/prestacion.py +272 -0
  232. coati_payroll/vistas/prestamo.py +808 -0
  233. coati_payroll/vistas/report.py +432 -0
  234. coati_payroll/vistas/settings.py +29 -0
  235. coati_payroll/vistas/tipo_planilla.py +134 -0
  236. coati_payroll/vistas/user.py +172 -0
  237. coati_payroll/vistas/vacation.py +1045 -0
  238. coati_payroll-0.0.2.dist-info/LICENSE +201 -0
  239. coati_payroll-0.0.2.dist-info/METADATA +581 -0
  240. coati_payroll-0.0.2.dist-info/RECORD +243 -0
  241. coati_payroll-0.0.2.dist-info/WHEEL +5 -0
  242. coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
  243. coati_payroll-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1045 @@
1
+ # Copyright 2025 BMO Soluciones, S.A.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Vacation module views.
15
+
16
+ This module provides views for managing vacation policies, accounts, and leave requests.
17
+ Implements a robust, auditable, and country-agnostic vacation management system.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import date
23
+ from decimal import Decimal
24
+
25
+ from flask import Blueprint, flash, redirect, render_template, request, url_for, jsonify
26
+ from flask_login import current_user, login_required
27
+ from sqlalchemy import func
28
+ from sqlalchemy.orm import selectinload
29
+
30
+ from coati_payroll.enums import TipoUsuario, VacationLedgerType
31
+ from coati_payroll.i18n import _
32
+ from coati_payroll.model import (
33
+ db,
34
+ VacationPolicy,
35
+ VacationAccount,
36
+ VacationLedger,
37
+ VacationNovelty,
38
+ Empleado,
39
+ Empresa,
40
+ )
41
+ from coati_payroll.rbac import require_role, require_read_access, require_write_access
42
+
43
+ vacation_bp = Blueprint("vacation", __name__, url_prefix="/vacation")
44
+
45
+
46
+ # ============================================================================
47
+ # Vacation Policy Management
48
+ # ============================================================================
49
+
50
+
51
+ @vacation_bp.route("/policies")
52
+ @require_read_access()
53
+ def policy_index():
54
+ """List all vacation policies."""
55
+ page = request.args.get("page", 1, type=int)
56
+ per_page = 20
57
+
58
+ query = db.select(VacationPolicy).order_by(VacationPolicy.nombre)
59
+ pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
60
+ policies = pagination.items
61
+
62
+ return render_template(
63
+ "modules/vacation/policy_index.html",
64
+ policies=policies,
65
+ pagination=pagination,
66
+ )
67
+
68
+
69
+ @vacation_bp.route("/policies/new", methods=["GET", "POST"])
70
+ @require_role(TipoUsuario.ADMIN)
71
+ def policy_new():
72
+ """Create a new vacation policy. Only administrators can create policies."""
73
+ from coati_payroll.forms import VacationPolicyForm
74
+ from coati_payroll.model import Planilla
75
+
76
+ form = VacationPolicyForm()
77
+
78
+ # Populate planilla choices
79
+ planillas = (
80
+ db.session.execute(
81
+ db.select(Planilla)
82
+ .options(selectinload(Planilla.empresa))
83
+ .filter(Planilla.activo.is_(True))
84
+ .order_by(Planilla.nombre)
85
+ )
86
+ .scalars()
87
+ .all()
88
+ )
89
+ form.planilla_id.choices = [("", _("-- Seleccionar Planilla --"))] + [
90
+ (p.id, f"{p.nombre} ({p.empresa.razon_social if p.empresa else 'N/A'})") for p in planillas
91
+ ]
92
+
93
+ # Populate empresa choices
94
+ empresas = (
95
+ db.session.execute(db.select(Empresa).filter(Empresa.activo.is_(True)).order_by(Empresa.razon_social))
96
+ .scalars()
97
+ .all()
98
+ )
99
+ form.empresa_id.choices = [("", _("-- Seleccionar Empresa --"))] + [(e.id, e.razon_social) for e in empresas]
100
+
101
+ if form.validate_on_submit():
102
+ policy = VacationPolicy()
103
+ form.populate_obj(policy)
104
+ policy.creado_por = current_user.usuario
105
+
106
+ db.session.add(policy)
107
+ try:
108
+ db.session.commit()
109
+ flash(_("Política de vacaciones creada exitosamente."), "success")
110
+ return redirect(url_for("vacation.policy_index"))
111
+ except Exception as e:
112
+ db.session.rollback()
113
+ flash(_("Error al crear la política: {}").format(str(e)), "danger")
114
+
115
+ return render_template(
116
+ "modules/vacation/policy_form.html",
117
+ form=form,
118
+ titulo=_("Nueva Política de Vacaciones"),
119
+ )
120
+
121
+
122
+ @vacation_bp.route("/policies/<string:policy_id>/edit", methods=["GET", "POST"])
123
+ @require_role(TipoUsuario.ADMIN)
124
+ def policy_edit(policy_id):
125
+ """Edit an existing vacation policy. Only administrators can edit policies."""
126
+ from coati_payroll.forms import VacationPolicyForm
127
+ from coati_payroll.model import Planilla
128
+
129
+ policy = db.session.get(VacationPolicy, policy_id)
130
+ if not policy:
131
+ flash(_("Política no encontrada."), "warning")
132
+ return redirect(url_for("vacation.policy_index"))
133
+
134
+ form = VacationPolicyForm(obj=policy)
135
+
136
+ # Populate planilla choices
137
+ planillas = (
138
+ db.session.execute(
139
+ db.select(Planilla)
140
+ .options(selectinload(Planilla.empresa))
141
+ .filter(Planilla.activo.is_(True))
142
+ .order_by(Planilla.nombre)
143
+ )
144
+ .scalars()
145
+ .all()
146
+ )
147
+ form.planilla_id.choices = [("", _("-- Seleccionar Planilla --"))] + [
148
+ (p.id, f"{p.nombre} ({p.empresa.razon_social if p.empresa else 'N/A'})") for p in planillas
149
+ ]
150
+
151
+ # Populate empresa choices
152
+ empresas = (
153
+ db.session.execute(db.select(Empresa).filter(Empresa.activo.is_(True)).order_by(Empresa.razon_social))
154
+ .scalars()
155
+ .all()
156
+ )
157
+ form.empresa_id.choices = [("", _("-- Seleccionar Empresa --"))] + [(e.id, e.razon_social) for e in empresas]
158
+
159
+ if form.validate_on_submit():
160
+ form.populate_obj(policy)
161
+ policy.modificado_por = current_user.usuario
162
+
163
+ try:
164
+ db.session.commit()
165
+ flash(_("Política actualizada exitosamente."), "success")
166
+ return redirect(url_for("vacation.policy_index"))
167
+ except Exception as e:
168
+ db.session.rollback()
169
+ flash(_("Error al actualizar la política: {}").format(str(e)), "danger")
170
+
171
+ return render_template(
172
+ "modules/vacation/policy_form.html",
173
+ form=form,
174
+ policy=policy,
175
+ titulo=_("Editar Política de Vacaciones"),
176
+ )
177
+
178
+
179
+ @vacation_bp.route("/policies/<string:policy_id>")
180
+ @require_read_access()
181
+ def policy_detail(policy_id):
182
+ """View vacation policy details."""
183
+ policy = db.session.get(VacationPolicy, policy_id)
184
+ if not policy:
185
+ flash(_("Política no encontrada."), "warning")
186
+ return redirect(url_for("vacation.policy_index"))
187
+
188
+ # Get statistics
189
+ total_accounts = (
190
+ db.session.execute(
191
+ db.select(func.count(VacationAccount.id)).filter(VacationAccount.policy_id == policy_id)
192
+ ).scalar()
193
+ or 0
194
+ )
195
+
196
+ return render_template(
197
+ "modules/vacation/policy_detail.html",
198
+ policy=policy,
199
+ total_accounts=total_accounts,
200
+ )
201
+
202
+
203
+ # ============================================================================
204
+ # Vacation Account Management
205
+ # ============================================================================
206
+
207
+
208
+ @vacation_bp.route("/accounts")
209
+ @require_read_access()
210
+ def account_index():
211
+ """List all vacation accounts."""
212
+ page = request.args.get("page", 1, type=int)
213
+ per_page = 20
214
+
215
+ # Join with Empleado to get employee details
216
+ query = (
217
+ db.select(VacationAccount)
218
+ .join(VacationAccount.empleado)
219
+ .order_by(Empleado.primer_apellido, Empleado.primer_nombre)
220
+ )
221
+ pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
222
+ accounts = pagination.items
223
+
224
+ return render_template(
225
+ "modules/vacation/account_index.html",
226
+ accounts=accounts,
227
+ pagination=pagination,
228
+ )
229
+
230
+
231
+ @vacation_bp.route("/accounts/<string:account_id>")
232
+ @require_read_access()
233
+ def account_detail(account_id):
234
+ """View vacation account details and history."""
235
+ account = db.session.get(VacationAccount, account_id)
236
+ if not account:
237
+ flash(_("Cuenta no encontrada."), "warning")
238
+ return redirect(url_for("vacation.account_index"))
239
+
240
+ # Get ledger history
241
+ ledger_entries = (
242
+ db.session.execute(
243
+ db.select(VacationLedger)
244
+ .filter(VacationLedger.account_id == account_id)
245
+ .order_by(VacationLedger.fecha.desc())
246
+ .limit(50)
247
+ )
248
+ .scalars()
249
+ .all()
250
+ )
251
+
252
+ # Get pending leave requests
253
+ pending_requests = (
254
+ db.session.execute(
255
+ db.select(VacationNovelty)
256
+ .filter(VacationNovelty.account_id == account_id, VacationNovelty.estado == "pendiente")
257
+ .order_by(VacationNovelty.start_date)
258
+ )
259
+ .scalars()
260
+ .all()
261
+ )
262
+
263
+ return render_template(
264
+ "modules/vacation/account_detail.html",
265
+ account=account,
266
+ ledger_entries=ledger_entries,
267
+ pending_requests=pending_requests,
268
+ )
269
+
270
+
271
+ @vacation_bp.route("/accounts/new", methods=["GET", "POST"])
272
+ @require_role(TipoUsuario.ADMIN)
273
+ def account_new():
274
+ """Create a new vacation account for an employee."""
275
+ from coati_payroll.forms import VacationAccountForm
276
+
277
+ form = VacationAccountForm()
278
+
279
+ if form.validate_on_submit():
280
+ account = VacationAccount()
281
+ form.populate_obj(account)
282
+ account.creado_por = current_user.usuario
283
+
284
+ db.session.add(account)
285
+ try:
286
+ db.session.commit()
287
+ flash(_("Cuenta de vacaciones creada exitosamente."), "success")
288
+ return redirect(url_for("vacation.account_detail", account_id=account.id))
289
+ except Exception as e:
290
+ db.session.rollback()
291
+ flash(_("Error al crear la cuenta: {}").format(str(e)), "danger")
292
+
293
+ return render_template(
294
+ "modules/vacation/account_form.html",
295
+ form=form,
296
+ titulo=_("Nueva Cuenta de Vacaciones"),
297
+ )
298
+
299
+
300
+ # ============================================================================
301
+ # Vacation Leave Request Management
302
+ # ============================================================================
303
+
304
+
305
+ @vacation_bp.route("/leave-requests")
306
+ @require_read_access()
307
+ def leave_request_index():
308
+ """List vacation leave requests."""
309
+ page = request.args.get("page", 1, type=int)
310
+ per_page = 20
311
+ estado = request.args.get("estado", None)
312
+
313
+ query = db.select(VacationNovelty).join(VacationNovelty.empleado)
314
+
315
+ if estado:
316
+ query = query.filter(VacationNovelty.estado == estado)
317
+
318
+ query = query.order_by(VacationNovelty.start_date.desc())
319
+ pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
320
+ leave_requests = pagination.items
321
+
322
+ return render_template(
323
+ "modules/vacation/leave_request_index.html",
324
+ leave_requests=leave_requests,
325
+ pagination=pagination,
326
+ estado=estado,
327
+ )
328
+
329
+
330
+ @vacation_bp.route("/leave-requests/new", methods=["GET", "POST"])
331
+ @require_write_access()
332
+ def leave_request_new():
333
+ """Create a new vacation leave request."""
334
+ from coati_payroll.forms import VacationLeaveRequestForm
335
+
336
+ form = VacationLeaveRequestForm()
337
+
338
+ if form.validate_on_submit():
339
+ # Validate that employee has a vacation account
340
+ account = db.session.execute(
341
+ db.select(VacationAccount).filter(
342
+ VacationAccount.empleado_id == form.empleado_id.data, VacationAccount.activo.is_(True)
343
+ )
344
+ ).scalar_one_or_none()
345
+
346
+ if not account:
347
+ flash(_("El empleado no tiene una cuenta de vacaciones activa."), "danger")
348
+ return render_template(
349
+ "modules/vacation/leave_request_form.html",
350
+ form=form,
351
+ titulo=_("Nueva Solicitud de Vacaciones"),
352
+ )
353
+
354
+ # Check balance
355
+ if account.current_balance < form.units.data:
356
+ if not account.policy.allow_negative:
357
+ flash(_("Saldo insuficiente para la solicitud."), "danger")
358
+ return render_template(
359
+ "modules/vacation/leave_request_form.html",
360
+ form=form,
361
+ titulo=_("Nueva Solicitud de Vacaciones"),
362
+ )
363
+
364
+ # Create leave request
365
+ leave_request = VacationNovelty()
366
+ form.populate_obj(leave_request)
367
+ leave_request.account_id = account.id
368
+ leave_request.creado_por = current_user.usuario
369
+
370
+ db.session.add(leave_request)
371
+ try:
372
+ db.session.commit()
373
+ flash(_("Solicitud de vacaciones creada exitosamente."), "success")
374
+ return redirect(url_for("vacation.leave_request_detail", request_id=leave_request.id))
375
+ except Exception as e:
376
+ db.session.rollback()
377
+ flash(_("Error al crear la solicitud: {}").format(str(e)), "danger")
378
+
379
+ return render_template(
380
+ "modules/vacation/leave_request_form.html",
381
+ form=form,
382
+ titulo=_("Nueva Solicitud de Vacaciones"),
383
+ )
384
+
385
+
386
+ @vacation_bp.route("/leave-requests/<string:request_id>")
387
+ @require_read_access()
388
+ def leave_request_detail(request_id):
389
+ """View vacation leave request details."""
390
+ leave_request = db.session.get(VacationNovelty, request_id)
391
+ if not leave_request:
392
+ flash(_("Solicitud no encontrada."), "warning")
393
+ return redirect(url_for("vacation.leave_request_index"))
394
+
395
+ return render_template(
396
+ "modules/vacation/leave_request_detail.html",
397
+ leave_request=leave_request,
398
+ )
399
+
400
+
401
+ @vacation_bp.route("/leave-requests/<string:request_id>/approve", methods=["POST"])
402
+ @require_role(TipoUsuario.ADMIN)
403
+ def leave_request_approve(request_id):
404
+ """Approve a vacation leave request and create ledger entry."""
405
+ leave_request = db.session.get(VacationNovelty, request_id)
406
+ if not leave_request:
407
+ flash(_("Solicitud no encontrada."), "warning")
408
+ return redirect(url_for("vacation.leave_request_index"))
409
+
410
+ if leave_request.estado != "pendiente":
411
+ flash(_("Solo se pueden aprobar solicitudes pendientes."), "warning")
412
+ return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
413
+
414
+ # Update request status
415
+ leave_request.estado = "aprobado"
416
+ leave_request.fecha_aprobacion = date.today()
417
+ leave_request.aprobado_por = current_user.usuario
418
+ leave_request.modificado_por = current_user.usuario
419
+
420
+ # Create ledger entry for usage
421
+ ledger_entry = VacationLedger()
422
+ ledger_entry.account_id = leave_request.account_id
423
+ ledger_entry.empleado_id = leave_request.empleado_id
424
+ ledger_entry.fecha = date.today()
425
+ ledger_entry.entry_type = VacationLedgerType.USAGE
426
+ ledger_entry.quantity = -abs(leave_request.units) # Negative for usage
427
+ ledger_entry.source = "novelty"
428
+ ledger_entry.reference_id = leave_request.id
429
+ ledger_entry.reference_type = "vacation_novelty"
430
+ ledger_entry.observaciones = f"Vacaciones del {leave_request.start_date} al {leave_request.end_date}"
431
+ ledger_entry.creado_por = current_user.usuario
432
+
433
+ # Update account balance
434
+ account = leave_request.account
435
+ account.current_balance = account.current_balance - abs(leave_request.units)
436
+ ledger_entry.balance_after = account.current_balance
437
+ account.modificado_por = current_user.usuario
438
+
439
+ db.session.add(ledger_entry)
440
+ db.session.flush()
441
+
442
+ # Link ledger entry to request (after flush so ID is available)
443
+ leave_request.ledger_entry_id = ledger_entry.id
444
+
445
+ try:
446
+ db.session.commit()
447
+ flash(_("Solicitud aprobada exitosamente."), "success")
448
+ except Exception as e:
449
+ db.session.rollback()
450
+ flash(_("Error al aprobar la solicitud: {}").format(str(e)), "danger")
451
+
452
+ return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
453
+
454
+
455
+ @vacation_bp.route("/leave-requests/<string:request_id>/reject", methods=["POST"])
456
+ @require_role(TipoUsuario.ADMIN)
457
+ def leave_request_reject(request_id):
458
+ """Reject a vacation leave request."""
459
+ leave_request = db.session.get(VacationNovelty, request_id)
460
+ if not leave_request:
461
+ flash(_("Solicitud no encontrada."), "warning")
462
+ return redirect(url_for("vacation.leave_request_index"))
463
+
464
+ if leave_request.estado != "pendiente":
465
+ flash(_("Solo se pueden rechazar solicitudes pendientes."), "warning")
466
+ return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
467
+
468
+ # Get rejection reason from form
469
+ motivo_rechazo = request.form.get("motivo_rechazo", "")
470
+
471
+ # Update request status
472
+ leave_request.estado = "rechazado"
473
+ leave_request.motivo_rechazo = motivo_rechazo
474
+ leave_request.modificado_por = current_user.usuario
475
+
476
+ try:
477
+ db.session.commit()
478
+ flash(_("Solicitud rechazada."), "info")
479
+ except Exception as e:
480
+ db.session.rollback()
481
+ flash(_("Error al rechazar la solicitud: {}").format(str(e)), "danger")
482
+
483
+ return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
484
+
485
+
486
+ # ============================================================================
487
+ # Register Vacation Taken (Direct Registration with Novelty Creation)
488
+ # ============================================================================
489
+
490
+
491
+ @vacation_bp.route("/register-taken", methods=["GET", "POST"])
492
+ @require_write_access()
493
+ def register_vacation_taken():
494
+ """Register vacation days taken by an employee (creates vacation record + novelty).
495
+
496
+ This is an alternative method to register novelties from the vacation module.
497
+ The novelty is created using the existing infrastructure (NominaNovedad) and MUST
498
+ be associated with either a Percepcion or Deduccion for payroll calculations.
499
+
500
+ Workflow:
501
+ 1. Creates a VacationNovelty record (vacation tracking)
502
+ 2. Creates a linked NominaNovedad record (payroll integration)
503
+ 3. The NominaNovedad is associated with the selected Percepcion/Deduccion
504
+ 4. Marks vacation as approved and deducts from balance
505
+ 5. When payroll is calculated, the novelty will be processed normally
506
+ """
507
+ from coati_payroll.forms import VacationTakenForm
508
+ from coati_payroll.model import NominaNovedad, Percepcion, Deduccion
509
+
510
+ form = VacationTakenForm()
511
+
512
+ # Populate employee choices
513
+ empleados = (
514
+ db.session.execute(
515
+ db.select(Empleado)
516
+ .filter(Empleado.activo.is_(True))
517
+ .order_by(Empleado.primer_apellido, Empleado.primer_nombre)
518
+ )
519
+ .scalars()
520
+ .all()
521
+ )
522
+ form.empleado_id.choices = [("", _("-- Seleccionar Empleado --"))] + [
523
+ (e.id, f"{e.codigo_empleado} - {e.primer_nombre} {e.primer_apellido}") for e in empleados
524
+ ]
525
+
526
+ # Populate percepcion choices
527
+ percepciones = (
528
+ db.session.execute(db.select(Percepcion).filter(Percepcion.activo.is_(True)).order_by(Percepcion.codigo))
529
+ .scalars()
530
+ .all()
531
+ )
532
+ form.percepcion_id.choices = [("", _("-- Seleccionar Percepción --"))] + [
533
+ (p.id, f"{p.codigo} - {p.nombre}") for p in percepciones
534
+ ]
535
+
536
+ # Populate deduccion choices
537
+ deducciones = (
538
+ db.session.execute(db.select(Deduccion).filter(Deduccion.activo.is_(True)).order_by(Deduccion.codigo))
539
+ .scalars()
540
+ .all()
541
+ )
542
+ form.deduccion_id.choices = [("", _("-- Seleccionar Deducción --"))] + [
543
+ (d.id, f"{d.codigo} - {d.nombre}") for d in deducciones
544
+ ]
545
+
546
+ if form.validate_on_submit():
547
+ empleado_id = form.empleado_id.data
548
+ empleado = db.session.get(Empleado, empleado_id)
549
+
550
+ if not empleado:
551
+ flash(_("Empleado no encontrado."), "danger")
552
+ return render_template(
553
+ "modules/vacation/register_taken_form.html",
554
+ form=form,
555
+ titulo=_("Registrar Vacaciones Descansadas"),
556
+ )
557
+
558
+ # Validate tipo_concepto and associated percepcion/deduccion
559
+ tipo_concepto = form.tipo_concepto.data
560
+ percepcion_id = form.percepcion_id.data if tipo_concepto == "percepcion" else None
561
+ deduccion_id = form.deduccion_id.data if tipo_concepto == "deduccion" else None
562
+
563
+ if tipo_concepto == "percepcion" and not percepcion_id:
564
+ flash(_("Debe seleccionar una percepción cuando el tipo de concepto es percepción."), "danger")
565
+ return render_template(
566
+ "modules/vacation/register_taken_form.html",
567
+ form=form,
568
+ titulo=_("Registrar Vacaciones Descansadas"),
569
+ )
570
+
571
+ if tipo_concepto == "deduccion" and not deduccion_id:
572
+ flash(_("Debe seleccionar una deducción cuando el tipo de concepto es deducción."), "danger")
573
+ return render_template(
574
+ "modules/vacation/register_taken_form.html",
575
+ form=form,
576
+ titulo=_("Registrar Vacaciones Descansadas"),
577
+ )
578
+
579
+ # Get the concepto for codigo
580
+ if tipo_concepto == "percepcion":
581
+ concepto = db.session.get(Percepcion, percepcion_id)
582
+ codigo_concepto = concepto.codigo if concepto else "VACACIONES"
583
+ else:
584
+ concepto = db.session.get(Deduccion, deduccion_id)
585
+ codigo_concepto = concepto.codigo if concepto else "AUSENCIA"
586
+
587
+ # Validate that employee has a vacation account
588
+ account = db.session.execute(
589
+ db.select(VacationAccount).filter(
590
+ VacationAccount.empleado_id == empleado_id, VacationAccount.activo.is_(True)
591
+ )
592
+ ).scalar_one_or_none()
593
+
594
+ if not account:
595
+ flash(_("El empleado no tiene una cuenta de vacaciones activa. Cree una cuenta primero."), "danger")
596
+ return render_template(
597
+ "modules/vacation/register_taken_form.html",
598
+ form=form,
599
+ titulo=_("Registrar Vacaciones Descansadas"),
600
+ )
601
+
602
+ # Check balance (considering dias_descontados, not calendar days)
603
+ dias_descontados = form.dias_descontados.data
604
+ if account.current_balance < dias_descontados:
605
+ if not account.policy.allow_negative:
606
+ flash(
607
+ _(f"Saldo insuficiente. Balance actual: {account.current_balance}, Solicitado: {dias_descontados}"),
608
+ "danger",
609
+ )
610
+ return render_template(
611
+ "modules/vacation/register_taken_form.html",
612
+ form=form,
613
+ titulo=_("Registrar Vacaciones Descansadas"),
614
+ )
615
+
616
+ # Create VacationNovelty (leave record)
617
+ vacation_novelty = VacationNovelty(
618
+ empleado_id=empleado_id,
619
+ account_id=account.id,
620
+ start_date=form.fecha_inicio.data,
621
+ end_date=form.fecha_fin.data,
622
+ units=dias_descontados, # CRITICAL: Use dias_descontados, not calendar days
623
+ estado="aprobado", # Directly approved
624
+ fecha_aprobacion=date.today(),
625
+ aprobado_por=current_user.usuario,
626
+ observaciones=form.observaciones.data,
627
+ creado_por=current_user.usuario,
628
+ )
629
+
630
+ db.session.add(vacation_novelty)
631
+ db.session.flush() # Get ID
632
+
633
+ # Create VacationLedger entry
634
+ ledger_entry = VacationLedger(
635
+ account_id=account.id,
636
+ empleado_id=empleado_id,
637
+ fecha=form.fecha_fin.data,
638
+ entry_type=VacationLedgerType.USAGE,
639
+ quantity=-abs(dias_descontados), # Negative for usage
640
+ source="direct_registration",
641
+ reference_id=vacation_novelty.id,
642
+ reference_type="vacation_novelty",
643
+ observaciones=f"{form.fecha_inicio.data} - {form.fecha_fin.data} - {dias_descontados} descontados",
644
+ creado_por=current_user.usuario,
645
+ )
646
+
647
+ # Update account balance
648
+ account.current_balance = account.current_balance - abs(dias_descontados)
649
+ account.modificado_por = current_user.usuario
650
+
651
+ db.session.add(ledger_entry)
652
+ db.session.flush()
653
+
654
+ ledger_entry.balance_after = account.current_balance
655
+
656
+ # Link ledger entry to vacation novelty
657
+ vacation_novelty.ledger_entry_id = ledger_entry.id
658
+ vacation_novelty.estado = "disfrutado"
659
+
660
+ # Create associated NominaNovedad using existing infrastructure
661
+ # This ensures the novelty is properly processed during payroll calculation
662
+ nomina_novedad = NominaNovedad(
663
+ nomina_id=None, # Will be linked to the employee's next nomina when calculated
664
+ empleado_id=empleado_id,
665
+ tipo_valor="dias", # Or "horas" based on policy unit_type
666
+ codigo_concepto=codigo_concepto,
667
+ valor_cantidad=dias_descontados,
668
+ fecha_novedad=form.fecha_inicio.data,
669
+ percepcion_id=percepcion_id, # Required association
670
+ deduccion_id=deduccion_id, # Required association
671
+ es_descanso_vacaciones=True,
672
+ vacation_novelty_id=vacation_novelty.id,
673
+ fecha_inicio_descanso=form.fecha_inicio.data,
674
+ fecha_fin_descanso=form.fecha_fin.data,
675
+ estado="pendiente", # Will be processed when nomina is calculated
676
+ creado_por=current_user.usuario,
677
+ )
678
+
679
+ db.session.add(nomina_novedad)
680
+
681
+ try:
682
+ db.session.commit()
683
+ flash(_(f"Vacaciones registradas exitosamente. {dias_descontados} días descontados del saldo."), "success")
684
+ return redirect(url_for("vacation.account_detail", account_id=account.id))
685
+ except Exception as e:
686
+ db.session.rollback()
687
+ flash(_("Error al registrar vacaciones: {}").format(str(e)), "danger")
688
+
689
+ return render_template(
690
+ "modules/vacation/register_taken_form.html",
691
+ form=form,
692
+ titulo=_("Registrar Vacaciones Descansadas"),
693
+ )
694
+
695
+
696
+ # ============================================================================
697
+ # Vacation Dashboard
698
+ # ============================================================================
699
+
700
+
701
+ @vacation_bp.route("/")
702
+ @login_required
703
+ def dashboard():
704
+ """Vacation management dashboard."""
705
+ # Statistics
706
+ total_policies = (
707
+ db.session.execute(db.select(func.count(VacationPolicy.id)).filter(VacationPolicy.activo.is_(True))).scalar()
708
+ or 0
709
+ )
710
+
711
+ total_accounts = (
712
+ db.session.execute(db.select(func.count(VacationAccount.id)).filter(VacationAccount.activo.is_(True))).scalar()
713
+ or 0
714
+ )
715
+
716
+ pending_requests = (
717
+ db.session.execute(
718
+ db.select(func.count(VacationNovelty.id)).filter(VacationNovelty.estado == "pendiente")
719
+ ).scalar()
720
+ or 0
721
+ )
722
+
723
+ # Recent activity
724
+ recent_requests = (
725
+ db.session.execute(
726
+ db.select(VacationNovelty)
727
+ .join(VacationNovelty.empleado)
728
+ .order_by(VacationNovelty.timestamp.desc())
729
+ .limit(10)
730
+ )
731
+ .scalars()
732
+ .all()
733
+ )
734
+
735
+ return render_template(
736
+ "modules/vacation/dashboard.html",
737
+ total_policies=total_policies,
738
+ total_accounts=total_accounts,
739
+ pending_requests=pending_requests,
740
+ recent_requests=recent_requests,
741
+ )
742
+
743
+
744
+ # ============================================================================
745
+ # API Endpoints for AJAX
746
+ # ============================================================================
747
+
748
+
749
+ @vacation_bp.route("/api/employee/<string:employee_id>/balance")
750
+ @login_required
751
+ def api_employee_balance(employee_id):
752
+ """Get employee vacation balance (AJAX endpoint)."""
753
+ account = db.session.execute(
754
+ db.select(VacationAccount).filter(VacationAccount.empleado_id == employee_id, VacationAccount.activo.is_(True))
755
+ ).scalar_one_or_none()
756
+
757
+ if not account:
758
+ return jsonify({"error": "No vacation account found"}), 404
759
+
760
+ return jsonify(
761
+ {
762
+ "balance": float(account.current_balance),
763
+ "unit_type": account.policy.unit_type,
764
+ "policy_name": account.policy.nombre,
765
+ }
766
+ )
767
+
768
+
769
+ # ============================================================================
770
+ # Initial Balance Loading (for System Implementation)
771
+ # ============================================================================
772
+
773
+
774
+ @vacation_bp.route("/initial-balance", methods=["GET", "POST"])
775
+ @require_role(TipoUsuario.ADMIN)
776
+ def initial_balance_form():
777
+ """Load initial vacation balance for a single employee.
778
+
779
+ Used during system implementation to set the initial accumulated vacation
780
+ balance for employees who already have vacation time earned before the
781
+ system goes live.
782
+
783
+ Creates an ADJUSTMENT ledger entry with the initial balance and sets
784
+ the account's current_balance to match.
785
+ """
786
+ from coati_payroll.forms import VacationInitialBalanceForm
787
+
788
+ form = VacationInitialBalanceForm()
789
+
790
+ # Populate employee choices
791
+ empleados = (
792
+ db.session.execute(
793
+ db.select(Empleado)
794
+ .filter(Empleado.activo.is_(True))
795
+ .order_by(Empleado.primer_apellido, Empleado.primer_nombre)
796
+ )
797
+ .scalars()
798
+ .all()
799
+ )
800
+ form.empleado_id.choices = [("", _("-- Seleccionar Empleado --"))] + [
801
+ (e.id, f"{e.codigo_empleado} - {e.primer_nombre} {e.primer_apellido}") for e in empleados
802
+ ]
803
+
804
+ if form.validate_on_submit():
805
+ empleado_id = form.empleado_id.data
806
+ saldo_inicial = form.saldo_inicial.data
807
+ fecha_corte = form.fecha_corte.data
808
+ observaciones = form.observaciones.data or "Carga de saldo inicial al implementar el sistema"
809
+
810
+ # Get employee and their vacation account
811
+ empleado = db.session.get(Empleado, empleado_id)
812
+ if not empleado:
813
+ flash(_("Empleado no encontrado."), "danger")
814
+ return redirect(url_for("vacation.initial_balance_form"))
815
+
816
+ # Check if employee has an active vacation account
817
+ account = db.session.execute(
818
+ db.select(VacationAccount).filter(
819
+ VacationAccount.empleado_id == empleado_id, VacationAccount.activo.is_(True)
820
+ )
821
+ ).scalar_one_or_none()
822
+
823
+ if not account:
824
+ flash(
825
+ _(
826
+ "El empleado {} no tiene una cuenta de vacaciones activa. " "Por favor, cree una cuenta primero."
827
+ ).format(empleado.codigo_empleado),
828
+ "warning",
829
+ )
830
+ return redirect(url_for("vacation.account_index"))
831
+
832
+ # Check if there are already ledger entries for this account
833
+ existing_entries = (
834
+ db.session.execute(
835
+ db.select(func.count(VacationLedger.id)).filter(VacationLedger.account_id == account.id)
836
+ ).scalar()
837
+ or 0
838
+ )
839
+
840
+ if existing_entries > 0:
841
+ flash(
842
+ _(
843
+ "La cuenta de vacaciones del empleado {} ya tiene movimientos registrados. "
844
+ "No se puede cargar un saldo inicial para cuentas con historial."
845
+ ).format(empleado.codigo_empleado),
846
+ "warning",
847
+ )
848
+ return redirect(url_for("vacation.account_detail", account_id=account.id))
849
+
850
+ # Create ledger entry for initial balance
851
+ ledger_entry = VacationLedger(
852
+ account_id=account.id,
853
+ empleado_id=empleado_id,
854
+ fecha=fecha_corte,
855
+ entry_type=VacationLedgerType.ADJUSTMENT,
856
+ quantity=saldo_inicial,
857
+ source="initial_balance",
858
+ reference_type="manual",
859
+ observaciones=observaciones,
860
+ creado_por=current_user.usuario,
861
+ )
862
+
863
+ # Set account balance to initial balance
864
+ account.current_balance = saldo_inicial
865
+ account.last_accrual_date = fecha_corte
866
+ account.modificado_por = current_user.usuario
867
+
868
+ ledger_entry.balance_after = account.current_balance
869
+
870
+ db.session.add(ledger_entry)
871
+
872
+ try:
873
+ db.session.commit()
874
+ flash(
875
+ _("Saldo inicial de {} {} cargado exitosamente para {}.").format(
876
+ saldo_inicial, account.policy.unit_type, empleado.codigo_empleado
877
+ ),
878
+ "success",
879
+ )
880
+ return redirect(url_for("vacation.account_detail", account_id=account.id))
881
+ except Exception as e:
882
+ db.session.rollback()
883
+ flash(_("Error al cargar saldo inicial: {}").format(str(e)), "danger")
884
+
885
+ return render_template("modules/vacation/initial_balance_form.html", form=form)
886
+
887
+
888
+ @vacation_bp.route("/initial-balance/bulk", methods=["GET", "POST"])
889
+ @require_role(TipoUsuario.ADMIN)
890
+ def initial_balance_bulk():
891
+ """Bulk load initial vacation balances from Excel.
892
+
893
+ Used during system implementation for companies with many employees.
894
+ Allows uploading an Excel file with initial vacation balances for multiple
895
+ employees at once.
896
+
897
+ Expected Excel format (without headers, data starts on row 1):
898
+ - Column A: Código de Empleado
899
+ - Column B: Saldo Inicial (días/horas)
900
+ - Column C: Fecha de Corte (DD/MM/YYYY)
901
+ - Column D: Observaciones (opcional)
902
+ """
903
+ if request.method == "POST":
904
+ # Check if file was uploaded
905
+ if "file" not in request.files:
906
+ flash(_("No se seleccionó ningún archivo."), "warning")
907
+ return redirect(url_for("vacation.initial_balance_bulk"))
908
+
909
+ file = request.files["file"]
910
+
911
+ if file.filename == "":
912
+ flash(_("No se seleccionó ningún archivo."), "warning")
913
+ return redirect(url_for("vacation.initial_balance_bulk"))
914
+
915
+ if not file.filename.endswith((".xlsx", ".xls")):
916
+ flash(_("Por favor, suba un archivo Excel (.xlsx o .xls)."), "warning")
917
+ return redirect(url_for("vacation.initial_balance_bulk"))
918
+
919
+ try:
920
+ import openpyxl
921
+ from datetime import datetime as dt
922
+
923
+ # Load Excel file
924
+ workbook = openpyxl.load_workbook(file, data_only=True)
925
+ sheet = workbook.active
926
+
927
+ success_count = 0
928
+ error_count = 0
929
+ errors = []
930
+
931
+ # Process each row (data starts at row 1, no headers expected)
932
+ for row_num, row in enumerate(sheet.iter_rows(min_row=1, values_only=True), start=1):
933
+ codigo_empleado = row[0]
934
+ saldo_inicial = row[1]
935
+ fecha_corte = row[2]
936
+ observaciones = row[3] if len(row) > 3 else "Carga masiva de saldo inicial"
937
+
938
+ # Validate required fields
939
+ if not codigo_empleado or saldo_inicial is None or not fecha_corte:
940
+ errors.append(f"Fila {row_num}: Faltan campos requeridos")
941
+ error_count += 1
942
+ continue
943
+
944
+ # Convert fecha_corte if it's a datetime object
945
+ if isinstance(fecha_corte, dt):
946
+ fecha_corte = fecha_corte.date()
947
+ elif isinstance(fecha_corte, str):
948
+ try:
949
+ fecha_corte = dt.strptime(fecha_corte, "%d/%m/%Y").date()
950
+ except ValueError:
951
+ errors.append(f"Fila {row_num}: Formato de fecha inválido (use DD/MM/YYYY)")
952
+ error_count += 1
953
+ continue
954
+
955
+ # Find employee
956
+ empleado = db.session.execute(
957
+ db.select(Empleado).filter(Empleado.codigo_empleado == codigo_empleado, Empleado.activo.is_(True))
958
+ ).scalar_one_or_none()
959
+
960
+ if not empleado:
961
+ errors.append(f"Fila {row_num}: Empleado {codigo_empleado} no encontrado")
962
+ error_count += 1
963
+ continue
964
+
965
+ # Check if employee has an active vacation account
966
+ account = db.session.execute(
967
+ db.select(VacationAccount).filter(
968
+ VacationAccount.empleado_id == empleado.id, VacationAccount.activo.is_(True)
969
+ )
970
+ ).scalar_one_or_none()
971
+
972
+ if not account:
973
+ errors.append(f"Fila {row_num}: Empleado {codigo_empleado} no tiene cuenta de vacaciones activa")
974
+ error_count += 1
975
+ continue
976
+
977
+ # Check if account already has ledger entries
978
+ existing_entries = (
979
+ db.session.execute(
980
+ db.select(func.count(VacationLedger.id)).filter(VacationLedger.account_id == account.id)
981
+ ).scalar()
982
+ or 0
983
+ )
984
+
985
+ if existing_entries > 0:
986
+ errors.append(f"Fila {row_num}: Empleado {codigo_empleado} ya tiene movimientos en su cuenta")
987
+ error_count += 1
988
+ continue
989
+
990
+ try:
991
+ # Create ledger entry for initial balance
992
+ ledger_entry = VacationLedger(
993
+ account_id=account.id,
994
+ empleado_id=empleado.id,
995
+ fecha=fecha_corte,
996
+ entry_type=VacationLedgerType.ADJUSTMENT,
997
+ quantity=Decimal(str(saldo_inicial)),
998
+ source="initial_balance_bulk",
999
+ reference_type="excel_import",
1000
+ observaciones=str(observaciones) if observaciones else "Carga masiva de saldo inicial",
1001
+ creado_por=current_user.usuario,
1002
+ )
1003
+
1004
+ # Set account balance to initial balance
1005
+ account.current_balance = Decimal(str(saldo_inicial))
1006
+ account.last_accrual_date = fecha_corte
1007
+ account.modificado_por = current_user.usuario
1008
+
1009
+ ledger_entry.balance_after = account.current_balance
1010
+
1011
+ db.session.add(ledger_entry)
1012
+ success_count += 1
1013
+
1014
+ except Exception as e:
1015
+ errors.append(f"Fila {row_num}: Error al procesar {codigo_empleado}: {str(e)}")
1016
+ error_count += 1
1017
+ # Don't rollback here, continue adding successful entries
1018
+ continue
1019
+
1020
+ # Commit all changes
1021
+ try:
1022
+ db.session.commit()
1023
+ flash(
1024
+ _("Carga completada: {} registros exitosos, {} errores.").format(success_count, error_count),
1025
+ "success" if error_count == 0 else "warning",
1026
+ )
1027
+
1028
+ if errors:
1029
+ error_details = "<br>".join(errors[:10]) # Show first 10 errors
1030
+ if len(errors) > 10:
1031
+ error_details += f"<br>...y {len(errors) - 10} errores más"
1032
+ flash(error_details, "warning")
1033
+
1034
+ except Exception as e:
1035
+ db.session.rollback()
1036
+ flash(_("Error al guardar los cambios: {}").format(str(e)), "danger")
1037
+
1038
+ except ImportError:
1039
+ flash(_("Error: La librería openpyxl no está instalada. Contacte al administrador."), "danger")
1040
+ except Exception as e:
1041
+ flash(_("Error al procesar el archivo Excel: {}").format(str(e)), "danger")
1042
+
1043
+ return redirect(url_for("vacation.initial_balance_bulk"))
1044
+
1045
+ return render_template("modules/vacation/initial_balance_bulk.html")