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,488 @@
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
+ """Routes for nomina execution and management."""
15
+
16
+ from datetime import date
17
+ from flask import flash, jsonify, redirect, render_template, request, url_for
18
+ from flask_login import current_user, login_required
19
+
20
+ from coati_payroll.model import db, Planilla, Nomina, NominaEmpleado, NominaDetalle, NominaNovedad
21
+ from coati_payroll.enums import NominaEstado, NovedadEstado
22
+ from coati_payroll.i18n import _
23
+ from coati_payroll.rbac import require_read_access, require_write_access
24
+ from coati_payroll.vistas.planilla import planilla_bp
25
+ from coati_payroll.vistas.planilla.services import NominaService
26
+ from coati_payroll.queue.tasks import retry_failed_nomina
27
+
28
+ # Constants
29
+ ROUTE_EJECUTAR_NOMINA = "planilla.ejecutar_nomina"
30
+ ROUTE_VER_NOMINA = "planilla.ver_nomina"
31
+ ROUTE_LISTAR_NOMINAS = "planilla.listar_nominas"
32
+ ERROR_NOMINA_NO_PERTENECE = "La nómina no pertenece a esta planilla."
33
+
34
+
35
+ @planilla_bp.route("/<planilla_id>/ejecutar", methods=["GET", "POST"])
36
+ @require_write_access()
37
+ def ejecutar_nomina(planilla_id: str):
38
+ """Execute a payroll run for a planilla."""
39
+ planilla = db.get_or_404(Planilla, planilla_id)
40
+
41
+ if request.method == "POST":
42
+ periodo_inicio = request.form.get("periodo_inicio")
43
+ periodo_fin = request.form.get("periodo_fin")
44
+ fecha_calculo = request.form.get("fecha_calculo")
45
+
46
+ if not periodo_inicio or not periodo_fin:
47
+ flash(_("Debe especificar el período de la nómina."), "error")
48
+ return redirect(url_for(ROUTE_EJECUTAR_NOMINA, planilla_id=planilla_id))
49
+
50
+ # Parse dates
51
+ try:
52
+ periodo_inicio = date.fromisoformat(periodo_inicio)
53
+ periodo_fin = date.fromisoformat(periodo_fin)
54
+ fecha_calculo = date.fromisoformat(fecha_calculo) if fecha_calculo else date.today()
55
+ except ValueError:
56
+ flash(_("Formato de fecha inválido."), "error")
57
+ return redirect(url_for(ROUTE_EJECUTAR_NOMINA, planilla_id=planilla_id))
58
+
59
+ nomina, errors, warnings = NominaService.ejecutar_nomina(
60
+ planilla=planilla,
61
+ periodo_inicio=periodo_inicio,
62
+ periodo_fin=periodo_fin,
63
+ fecha_calculo=fecha_calculo,
64
+ usuario=current_user.usuario,
65
+ )
66
+
67
+ if errors:
68
+ for error in errors:
69
+ flash(error, "error")
70
+
71
+ if warnings:
72
+ for warning in warnings:
73
+ flash(warning, "warning")
74
+
75
+ if nomina:
76
+ if nomina.procesamiento_en_background:
77
+ num_empleados = nomina.total_empleados or 0
78
+ flash(
79
+ _(
80
+ "La nómina está siendo calculada en segundo plano. "
81
+ "Se procesarán %(num)d empleados. "
82
+ "Por favor, revise el progreso en unos momentos.",
83
+ num=num_empleados,
84
+ ),
85
+ "info",
86
+ )
87
+ else:
88
+ flash(_("Nómina generada exitosamente."), "success")
89
+ return redirect(
90
+ url_for(
91
+ ROUTE_VER_NOMINA,
92
+ planilla_id=planilla_id,
93
+ nomina_id=nomina.id,
94
+ )
95
+ )
96
+ else:
97
+ return redirect(url_for(ROUTE_EJECUTAR_NOMINA, planilla_id=planilla_id))
98
+
99
+ # GET - show execution form
100
+ periodo_inicio, periodo_fin = NominaService.calcular_periodo_sugerido(planilla)
101
+ hoy = date.today()
102
+
103
+ # Get last nomina for reference
104
+ ultima_nomina = db.session.execute(
105
+ db.select(Nomina).filter_by(planilla_id=planilla_id).order_by(Nomina.periodo_fin.desc())
106
+ ).scalar_one_or_none()
107
+
108
+ return render_template(
109
+ "modules/planilla/ejecutar_nomina.html",
110
+ planilla=planilla,
111
+ periodo_inicio=periodo_inicio,
112
+ periodo_fin=periodo_fin,
113
+ fecha_calculo=hoy,
114
+ ultima_nomina=ultima_nomina,
115
+ )
116
+
117
+
118
+ @planilla_bp.route("/<planilla_id>/nominas")
119
+ @require_read_access()
120
+ def listar_nominas(planilla_id: str):
121
+ """List all nominas for a planilla."""
122
+ planilla = db.get_or_404(Planilla, planilla_id)
123
+ nominas = (
124
+ db.session.execute(db.select(Nomina).filter_by(planilla_id=planilla_id).order_by(Nomina.periodo_fin.desc()))
125
+ .scalars()
126
+ .all()
127
+ )
128
+
129
+ return render_template(
130
+ "modules/planilla/listar_nominas.html",
131
+ planilla=planilla,
132
+ nominas=nominas,
133
+ )
134
+
135
+
136
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>")
137
+ @require_read_access()
138
+ def ver_nomina(planilla_id: str, nomina_id: str):
139
+ """View details of a specific nomina."""
140
+ planilla = db.get_or_404(Planilla, planilla_id)
141
+ nomina = db.get_or_404(Nomina, nomina_id)
142
+
143
+ if nomina.planilla_id != planilla_id:
144
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
145
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
146
+
147
+ nomina_empleados = db.session.execute(db.select(NominaEmpleado).filter_by(nomina_id=nomina_id)).scalars().all()
148
+
149
+ # Check for errors and warnings in the processing log
150
+ has_errors = _nomina_has_errors(nomina)
151
+ has_warnings = _nomina_has_warnings(nomina)
152
+
153
+ # Get error and warning messages for display
154
+ error_messages = []
155
+ warning_messages = []
156
+ if nomina.log_procesamiento:
157
+ for entry in nomina.log_procesamiento:
158
+ status = entry.get("status") or entry.get("tipo")
159
+ message = entry.get("message") or entry.get("mensaje") or ""
160
+ if status == "error":
161
+ error_messages.append(message)
162
+ elif status in ("warning", "advertencia_contabilidad"):
163
+ warning_messages.append(message)
164
+
165
+ return render_template(
166
+ "modules/planilla/ver_nomina.html",
167
+ planilla=planilla,
168
+ nomina=nomina,
169
+ nomina_empleados=nomina_empleados,
170
+ has_errors=has_errors,
171
+ has_warnings=has_warnings,
172
+ error_messages=error_messages,
173
+ warning_messages=warning_messages,
174
+ )
175
+
176
+
177
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/empleado/<nomina_empleado_id>")
178
+ @require_read_access()
179
+ def ver_nomina_empleado(planilla_id: str, nomina_id: str, nomina_empleado_id: str):
180
+ """View details of an employee's payroll."""
181
+ planilla = db.get_or_404(Planilla, planilla_id)
182
+ nomina = db.get_or_404(Nomina, nomina_id)
183
+ nomina_empleado = db.get_or_404(NominaEmpleado, nomina_empleado_id)
184
+
185
+ if nomina_empleado.nomina_id != nomina_id:
186
+ flash(_("El detalle no pertenece a esta nómina."), "error")
187
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
188
+
189
+ detalles = (
190
+ db.session.execute(
191
+ db.select(NominaDetalle).filter_by(nomina_empleado_id=nomina_empleado_id).order_by(NominaDetalle.orden)
192
+ )
193
+ .scalars()
194
+ .all()
195
+ )
196
+
197
+ # Separate by type
198
+ percepciones = [d for d in detalles if d.tipo == "ingreso"]
199
+ deducciones = [d for d in detalles if d.tipo == "deduccion"]
200
+ prestaciones = [d for d in detalles if d.tipo == "prestacion"]
201
+
202
+ return render_template(
203
+ "modules/planilla/ver_nomina_empleado.html",
204
+ planilla=planilla,
205
+ nomina=nomina,
206
+ nomina_empleado=nomina_empleado,
207
+ percepciones=percepciones,
208
+ deducciones=deducciones,
209
+ prestaciones=prestaciones,
210
+ )
211
+
212
+
213
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/progreso")
214
+ @require_read_access()
215
+ def progreso_nomina(planilla_id: str, nomina_id: str):
216
+ """API endpoint to check calculation progress of a nomina."""
217
+ nomina = db.get_or_404(Nomina, nomina_id)
218
+
219
+ if nomina.planilla_id != planilla_id:
220
+ return jsonify({"error": "Nomina does not belong to this planilla"}), 404
221
+
222
+ return jsonify(
223
+ {
224
+ "estado": nomina.estado,
225
+ "total_empleados": nomina.total_empleados or 0,
226
+ "empleados_procesados": nomina.empleados_procesados or 0,
227
+ "empleados_con_error": nomina.empleados_con_error or 0,
228
+ "progreso_porcentaje": (
229
+ int((nomina.empleados_procesados / nomina.total_empleados) * 100)
230
+ if nomina.total_empleados and nomina.total_empleados > 0
231
+ else 0
232
+ ),
233
+ "errores_calculo": nomina.errores_calculo or {},
234
+ "procesamiento_en_background": nomina.procesamiento_en_background,
235
+ "empleado_actual": nomina.empleado_actual or "",
236
+ "log_procesamiento": nomina.log_procesamiento or [],
237
+ }
238
+ )
239
+
240
+
241
+ def _nomina_has_errors(nomina: Nomina) -> bool:
242
+ """Check if a nomina has errors in its processing log."""
243
+ if not nomina.log_procesamiento:
244
+ return False
245
+ for entry in nomina.log_procesamiento:
246
+ status = entry.get("status") or entry.get("tipo")
247
+ if status == "error":
248
+ return True
249
+ return False
250
+
251
+
252
+ def _nomina_has_warnings(nomina: Nomina) -> bool:
253
+ """Check if a nomina has warnings in its processing log."""
254
+ if not nomina.log_procesamiento:
255
+ return False
256
+ for entry in nomina.log_procesamiento:
257
+ status = entry.get("status") or entry.get("tipo")
258
+ if status in ("warning", "advertencia_contabilidad"):
259
+ return True
260
+ return False
261
+
262
+
263
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/aprobar", methods=["POST"])
264
+ @require_write_access()
265
+ def aprobar_nomina(planilla_id: str, nomina_id: str):
266
+ """Approve a nomina for payment."""
267
+ nomina = db.get_or_404(Nomina, nomina_id)
268
+
269
+ if nomina.planilla_id != planilla_id:
270
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
271
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
272
+
273
+ if nomina.estado != "generado":
274
+ flash(_("Solo se pueden aprobar nóminas en estado 'generado'."), "error")
275
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
276
+
277
+ # Check for errors in the processing log - cannot approve with errors
278
+ if _nomina_has_errors(nomina):
279
+ flash(
280
+ _(
281
+ "No se puede aprobar una nómina con errores de procesamiento. "
282
+ "Corrija los errores y recalcule la nómina antes de aprobar."
283
+ ),
284
+ "error",
285
+ )
286
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
287
+
288
+ nomina.estado = "aprobado"
289
+ nomina.modificado_por = current_user.usuario
290
+ db.session.commit()
291
+
292
+ flash(_("Nómina aprobada exitosamente."), "success")
293
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
294
+
295
+
296
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/aplicar", methods=["POST"])
297
+ @require_write_access()
298
+ def aplicar_nomina(planilla_id: str, nomina_id: str):
299
+ """Mark a nomina as applied (paid)."""
300
+ nomina = db.get_or_404(Nomina, nomina_id)
301
+
302
+ if nomina.planilla_id != planilla_id:
303
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
304
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
305
+
306
+ if nomina.estado != "aprobado":
307
+ flash(_("Solo se pueden aplicar nóminas en estado 'aprobado'."), "error")
308
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
309
+
310
+ nomina.estado = "aplicado"
311
+ nomina.modificado_por = current_user.usuario
312
+
313
+ # Actualizar estado de todas las novedades asociadas a "ejecutada"
314
+ planilla = db.get_or_404(Planilla, planilla_id)
315
+ empleado_ids = [pe.empleado_id for pe in planilla.planilla_empleados if pe.activo]
316
+
317
+ # Actualizar novedades que corresponden a este período
318
+ novedades = (
319
+ db.session.execute(
320
+ db.select(NominaNovedad).filter(
321
+ NominaNovedad.empleado_id.in_(empleado_ids),
322
+ NominaNovedad.fecha_novedad >= nomina.periodo_inicio,
323
+ NominaNovedad.fecha_novedad <= nomina.periodo_fin,
324
+ NominaNovedad.estado == NovedadEstado.PENDIENTE,
325
+ )
326
+ )
327
+ .scalars()
328
+ .all()
329
+ )
330
+
331
+ for novedad in novedades:
332
+ novedad.estado = NovedadEstado.EJECUTADA
333
+ novedad.modificado_por = current_user.usuario
334
+
335
+ db.session.commit()
336
+
337
+ flash(
338
+ _("Nómina aplicada exitosamente. {} novedad(es) marcadas como ejecutadas.").format(len(novedades)),
339
+ "success",
340
+ )
341
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
342
+
343
+
344
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/reintentar", methods=["POST"])
345
+ @require_write_access()
346
+ def reintentar_nomina(planilla_id: str, nomina_id: str):
347
+ """Retry processing a failed nomina."""
348
+ nomina = db.get_or_404(Nomina, nomina_id)
349
+
350
+ if nomina.planilla_id != planilla_id:
351
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
352
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
353
+
354
+ if nomina.estado != NominaEstado.ERROR:
355
+ flash(_("Solo se pueden reintentar nóminas en estado 'error'."), "error")
356
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
357
+
358
+ # Call the retry function
359
+ result = retry_failed_nomina(nomina_id, current_user.usuario)
360
+
361
+ if result.get("success"):
362
+ flash(
363
+ _("Reintento de nómina iniciado exitosamente. El procesamiento se realizará en segundo plano."), "success"
364
+ )
365
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
366
+ else:
367
+ flash(_("Error al reintentar la nómina: {}").format(result.get("error", "Error desconocido")), "error")
368
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
369
+
370
+
371
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/recalcular", methods=["POST"])
372
+ @require_write_access()
373
+ def recalcular_nomina(planilla_id: str, nomina_id: str):
374
+ """Recalculate an existing nomina."""
375
+ nomina = db.get_or_404(Nomina, nomina_id)
376
+ planilla = db.get_or_404(Planilla, planilla_id)
377
+
378
+ if nomina.planilla_id != planilla_id:
379
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
380
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
381
+
382
+ if nomina.estado == "aplicado":
383
+ flash(_("No se puede recalcular una nómina en estado 'aplicado' (pagada)."), "error")
384
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=nomina_id))
385
+
386
+ new_nomina, errors, warnings = NominaService.recalcular_nomina(nomina, planilla, current_user.usuario)
387
+
388
+ if errors:
389
+ for error in errors:
390
+ flash(error, "error")
391
+
392
+ if warnings:
393
+ for warning in warnings:
394
+ flash(warning, "warning")
395
+
396
+ if new_nomina:
397
+ flash(_("Nómina recalculada exitosamente."), "success")
398
+ return redirect(url_for(ROUTE_VER_NOMINA, planilla_id=planilla_id, nomina_id=new_nomina.id))
399
+ else:
400
+ flash(_("Error al recalcular la nómina."), "error")
401
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
402
+
403
+
404
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/log")
405
+ @require_read_access()
406
+ def ver_log_nomina(planilla_id: str, nomina_id: str):
407
+ """View execution log for a nomina including warnings and errors."""
408
+ planilla = db.get_or_404(Planilla, planilla_id)
409
+ nomina = db.get_or_404(Nomina, nomina_id)
410
+
411
+ if nomina.planilla_id != planilla_id:
412
+ flash(_(ERROR_NOMINA_NO_PERTENECE), "error")
413
+ return redirect(url_for(ROUTE_LISTAR_NOMINAS, planilla_id=planilla_id))
414
+
415
+ # Get log entries
416
+ log_entries = nomina.log_procesamiento or []
417
+
418
+ # Get comprobante warnings if exists
419
+ from coati_payroll.model import ComprobanteContable
420
+
421
+ comprobante = db.session.execute(db.select(ComprobanteContable).filter_by(nomina_id=nomina_id)).scalar_one_or_none()
422
+
423
+ comprobante_warnings = comprobante.advertencias if comprobante else []
424
+
425
+ return render_template(
426
+ "modules/planilla/log_nomina.html",
427
+ planilla=planilla,
428
+ nomina=nomina,
429
+ log_entries=log_entries,
430
+ comprobante_warnings=comprobante_warnings,
431
+ )
432
+
433
+
434
+ @planilla_bp.route("/<planilla_id>/nomina/<nomina_id>/regenerar-comprobante", methods=["POST"])
435
+ @login_required
436
+ @require_write_access()
437
+ def regenerar_comprobante_contable(planilla_id: str, nomina_id: str):
438
+ """Regenerate accounting voucher for an applied/paid nomina without recalculating.
439
+
440
+ This is useful when accounting configuration was incomplete at the time of calculation
441
+ and has been updated afterwards. Only regenerates the accounting entries based on
442
+ existing payroll calculations.
443
+ """
444
+ planilla = db.get_or_404(Planilla, planilla_id)
445
+ nomina = db.get_or_404(Nomina, nomina_id)
446
+
447
+ if nomina.planilla_id != planilla_id:
448
+ flash(_("La nómina no pertenece a esta planilla."), "error")
449
+ return redirect(url_for("planilla.listar_nominas", planilla_id=planilla_id))
450
+
451
+ # Only allow for applied or paid nominas
452
+ from coati_payroll.enums import NominaEstado
453
+
454
+ if nomina.estado not in (NominaEstado.APLICADO, NominaEstado.PAGADO):
455
+ flash(
456
+ _(
457
+ "Solo se puede regenerar el comprobante contable para nóminas en estado 'aplicado' o 'pagado'. "
458
+ "Para nóminas en otros estados, use 'recalcular'."
459
+ ),
460
+ "error",
461
+ )
462
+ return redirect(url_for("planilla.ver_nomina", planilla_id=planilla_id, nomina_id=nomina_id))
463
+
464
+ try:
465
+ from coati_payroll.nomina_engine.services.accounting_voucher_service import AccountingVoucherService
466
+ from flask_login import current_user
467
+
468
+ accounting_service = AccountingVoucherService(db.session)
469
+
470
+ # Regenerate voucher using existing nomina data
471
+ fecha_calculo = nomina.fecha_calculo_original or nomina.periodo_fin
472
+ usuario = current_user.nombre_usuario if current_user and current_user.is_authenticated else None
473
+ comprobante = accounting_service.generate_accounting_voucher(nomina, planilla, fecha_calculo, usuario)
474
+
475
+ db.session.commit()
476
+
477
+ flash(_("Comprobante contable regenerado exitosamente."), "success")
478
+
479
+ # Show warnings if configuration is still incomplete
480
+ if comprobante.advertencias:
481
+ for warning in comprobante.advertencias:
482
+ flash(warning, "warning")
483
+
484
+ except Exception as e:
485
+ db.session.rollback()
486
+ flash(_("Error al regenerar comprobante contable: {}").format(str(e)), "error")
487
+
488
+ return redirect(url_for("planilla.ver_log_nomina", planilla_id=planilla_id, nomina_id=nomina_id))