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,764 @@
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
+ """Background tasks for payroll processing.
15
+
16
+ This module defines tasks that can be executed in the background:
17
+ - Individual employee payroll calculations
18
+ - Bulk payroll processing for multiple employees
19
+ - Report generation
20
+ - Email notifications
21
+
22
+ Tasks are automatically registered with the available queue driver
23
+ (Dramatiq or Huey) and can be enqueued for background execution.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ from datetime import date, datetime, timezone
30
+ from decimal import Decimal
31
+
32
+ from sqlalchemy.orm import joinedload
33
+
34
+ from coati_payroll.log import log
35
+ from coati_payroll.model import (
36
+ db,
37
+ Empleado,
38
+ Planilla,
39
+ Nomina as NominaModel,
40
+ NominaEmpleado as NominaEmpleadoModel,
41
+ NominaDetalle as NominaDetalleModel,
42
+ PrestacionAcumulada,
43
+ AdelantoAbono,
44
+ InteresAdelanto,
45
+ )
46
+ from coati_payroll.nomina_engine import NominaEngine
47
+ from coati_payroll.queue import get_queue_driver
48
+
49
+
50
+ # Get the queue driver
51
+ queue = get_queue_driver()
52
+
53
+
54
+ def _get_payroll_retry_config() -> dict[str, int]:
55
+ """Get payroll retry configuration from environment variables.
56
+
57
+ Returns:
58
+ Dictionary with retry configuration:
59
+ {
60
+ "max_retries": int,
61
+ "min_backoff_ms": int,
62
+ "max_backoff_ms": int
63
+ }
64
+ """
65
+ return {
66
+ "max_retries": int(os.getenv("PAYROLL_MAX_RETRIES", "3")),
67
+ "min_backoff_ms": int(os.getenv("PAYROLL_MIN_BACKOFF_MS", "60000")), # 60 seconds
68
+ "max_backoff_ms": int(os.getenv("PAYROLL_MAX_BACKOFF_MS", "3600000")), # 1 hour
69
+ }
70
+
71
+
72
+ def _is_recoverable_error(error: Exception) -> bool:
73
+ """Determine if an error is recoverable (can be retried).
74
+
75
+ Recoverable errors are typically transient issues like:
76
+ - Database connection problems
77
+ - Network timeouts
78
+ - Temporary resource unavailability
79
+
80
+ Non-recoverable errors are typically:
81
+ - Validation errors
82
+ - Data integrity issues
83
+ - Configuration problems
84
+
85
+ Args:
86
+ error: The exception that occurred
87
+
88
+ Returns:
89
+ True if error is recoverable, False otherwise
90
+ """
91
+ error_msg = str(error).lower()
92
+
93
+ # Recoverable error patterns
94
+ recoverable_patterns = [
95
+ "connection",
96
+ "timeout",
97
+ "temporary",
98
+ "unavailable",
99
+ "deadlock",
100
+ "lock",
101
+ "network",
102
+ "socket",
103
+ "broken pipe",
104
+ ]
105
+
106
+ # Non-recoverable error patterns
107
+ non_recoverable_patterns = [
108
+ "validation",
109
+ "integrity",
110
+ "not found",
111
+ "invalid",
112
+ "missing",
113
+ "required",
114
+ ]
115
+
116
+ # Check for non-recoverable patterns first
117
+ for pattern in non_recoverable_patterns:
118
+ if pattern in error_msg:
119
+ return False
120
+
121
+ # Check for recoverable patterns
122
+ for pattern in recoverable_patterns:
123
+ if pattern in error_msg:
124
+ return True
125
+
126
+ # Default: assume recoverable for unknown errors (safer to retry)
127
+ return True
128
+
129
+
130
+ def retry_failed_nomina(nomina_id: str, usuario: str | None = None) -> dict[str, bool | str]:
131
+ """Retry processing a failed nomina.
132
+
133
+ This function allows manual retry of a nomina that failed during processing.
134
+ It will reset the nomina state and attempt to process it again.
135
+
136
+ Args:
137
+ nomina_id: ID of the failed nomina to retry
138
+ usuario: Username attempting the retry (optional)
139
+
140
+ Returns:
141
+ Dictionary with retry status:
142
+ {
143
+ "success": bool,
144
+ "message": str,
145
+ "error": str (if failed)
146
+ }
147
+ """
148
+ from coati_payroll.enums import NominaEstado
149
+
150
+ try:
151
+ log.info(f"Retrying failed nomina {nomina_id}")
152
+
153
+ # Load the nomina
154
+ nomina = db.session.get(NominaModel, nomina_id)
155
+ if not nomina:
156
+ return {
157
+ "success": False,
158
+ "error": "Nomina not found",
159
+ }
160
+
161
+ # Verify nomina is in ERROR state
162
+ if nomina.estado != NominaEstado.ERROR:
163
+ return {
164
+ "success": False,
165
+ "error": f"Nomina not in ERROR state (current: {nomina.estado}). Only failed nominas can be retried.",
166
+ }
167
+
168
+ # Get planilla information
169
+ planilla = db.session.get(Planilla, nomina.planilla_id)
170
+ if not planilla:
171
+ return {
172
+ "success": False,
173
+ "error": "Planilla not found",
174
+ }
175
+
176
+ # Reset nomina state for retry
177
+ nomina.estado = NominaEstado.CALCULANDO
178
+ nomina.empleados_procesados = 0
179
+ nomina.empleados_con_error = 0
180
+ nomina.errores_calculo = {}
181
+ nomina.log_procesamiento = []
182
+ nomina.empleado_actual = None
183
+
184
+ # Clear any partial data from previous attempt
185
+ _rollback_nomina_data(nomina_id)
186
+
187
+ db.session.commit()
188
+
189
+ # Enqueue the processing task again
190
+ fecha_calculo_str = nomina.fecha_generacion.date().isoformat() if nomina.fecha_generacion else None
191
+ periodo_inicio_str = nomina.periodo_inicio.isoformat()
192
+ periodo_fin_str = nomina.periodo_fin.isoformat()
193
+
194
+ task_id = queue.enqueue(
195
+ "process_large_payroll",
196
+ nomina_id=nomina_id,
197
+ planilla_id=nomina.planilla_id,
198
+ periodo_inicio=periodo_inicio_str,
199
+ periodo_fin=periodo_fin_str,
200
+ fecha_calculo=fecha_calculo_str,
201
+ usuario=usuario or nomina.generado_por,
202
+ )
203
+
204
+ log.info(f"Retry task enqueued for nomina {nomina_id}, task_id: {task_id}")
205
+
206
+ return {
207
+ "success": True,
208
+ "message": f"Retry task enqueued successfully. Task ID: {task_id}",
209
+ }
210
+
211
+ except Exception as e:
212
+ log.error(f"Error retrying nomina {nomina_id}: {e}")
213
+ db.session.rollback()
214
+ return {
215
+ "success": False,
216
+ "error": str(e),
217
+ }
218
+
219
+
220
+ def _rollback_nomina_data(nomina_id: str) -> None:
221
+ """Rollback all data created for a nomina during payroll processing.
222
+
223
+ This function removes all records created during payroll calculation:
224
+ - NominaEmpleado records
225
+ - NominaDetalle records
226
+ - PrestacionAcumulada transactions
227
+ - AdelantoAbono records
228
+ - InteresAdelanto records
229
+ - Reverts AcumuladoAnual changes
230
+
231
+ Args:
232
+ nomina_id: ID of the nomina to rollback
233
+ """
234
+ try:
235
+ log.info(f"Rolling back all data for nomina {nomina_id}")
236
+
237
+ # Get all NominaEmpleado records for this nomina
238
+ nomina_empleados = (
239
+ db.session.execute(db.select(NominaEmpleadoModel).filter(NominaEmpleadoModel.nomina_id == nomina_id))
240
+ .scalars()
241
+ .all()
242
+ )
243
+
244
+ # Collect all IDs for cascading deletes
245
+ nomina_empleado_ids = [ne.id for ne in nomina_empleados]
246
+
247
+ if nomina_empleado_ids:
248
+ # Delete NominaDetalle records (cascade from NominaEmpleado)
249
+ db.session.execute(
250
+ db.delete(NominaDetalleModel).filter(NominaDetalleModel.nomina_empleado_id.in_(nomina_empleado_ids))
251
+ )
252
+
253
+ # Delete NominaEmpleado records
254
+ db.session.execute(db.delete(NominaEmpleadoModel).filter(NominaEmpleadoModel.nomina_id == nomina_id))
255
+
256
+ # Delete PrestacionAcumulada transactions created for this nomina
257
+ db.session.execute(db.delete(PrestacionAcumulada).filter(PrestacionAcumulada.nomina_id == nomina_id))
258
+
259
+ # Delete AdelantoAbono records created for this nomina
260
+ db.session.execute(db.delete(AdelantoAbono).filter(AdelantoAbono.nomina_id == nomina_id))
261
+
262
+ # Delete InteresAdelanto records created for this nomina
263
+ db.session.execute(db.delete(InteresAdelanto).filter(InteresAdelanto.nomina_id == nomina_id))
264
+
265
+ # Note: AcumuladoAnual changes are reverted via transaction rollback
266
+ # VacationLedger entries are also reverted via transaction rollback
267
+
268
+ log.info(f"Successfully rolled back data for nomina {nomina_id}")
269
+
270
+ except Exception as e:
271
+ log.error(f"Error during rollback for nomina {nomina_id}: {e}")
272
+ raise
273
+
274
+
275
+ def calculate_employee_payroll(
276
+ empleado_id: str,
277
+ planilla_id: str,
278
+ periodo_inicio: str,
279
+ periodo_fin: str,
280
+ fecha_calculo: str | None = None,
281
+ usuario: str | None = None,
282
+ ) -> dict[str, str | Decimal | None]:
283
+ """Calculate payroll for a single employee (background task).
284
+
285
+ This task can be enqueued for background processing to avoid
286
+ blocking the main application when calculating large payrolls.
287
+
288
+ Args:
289
+ empleado_id: Employee ID (ULID string)
290
+ planilla_id: Planilla ID (ULID string)
291
+ periodo_inicio: Start date (ISO format: YYYY-MM-DD)
292
+ periodo_fin: End date (ISO format: YYYY-MM-DD)
293
+ fecha_calculo: Calculation date (ISO format, optional)
294
+ usuario: Username executing the payroll (optional)
295
+
296
+ Returns:
297
+ Dictionary with calculation results:
298
+ {
299
+ "empleado_id": str,
300
+ "salario_bruto": Decimal,
301
+ "salario_neto": Decimal,
302
+ "total_deducciones": Decimal,
303
+ "success": bool,
304
+ "error": str (if failed)
305
+ }
306
+ """
307
+ try:
308
+ log.info(f"Processing payroll for employee {empleado_id}")
309
+
310
+ # Convert date strings to date objects
311
+ periodo_inicio_date = date.fromisoformat(periodo_inicio)
312
+ periodo_fin_date = date.fromisoformat(periodo_fin)
313
+ fecha_calculo_date = date.fromisoformat(fecha_calculo) if fecha_calculo else None
314
+
315
+ # Load employee and planilla
316
+ empleado = db.session.get(Empleado, empleado_id)
317
+ if not empleado:
318
+ return {
319
+ "empleado_id": empleado_id,
320
+ "success": False,
321
+ "error": "Employee not found",
322
+ }
323
+
324
+ planilla = db.session.get(Planilla, planilla_id)
325
+ if not planilla:
326
+ return {
327
+ "empleado_id": empleado_id,
328
+ "success": False,
329
+ "error": "Planilla not found",
330
+ }
331
+
332
+ # Initialize engine for single employee
333
+ engine = NominaEngine(
334
+ planilla=planilla,
335
+ periodo_inicio=periodo_inicio_date,
336
+ periodo_fin=periodo_fin_date,
337
+ fecha_calculo=fecha_calculo_date,
338
+ usuario=usuario,
339
+ )
340
+
341
+ # Process only this employee
342
+ emp_calculo = engine._procesar_empleado(empleado)
343
+
344
+ # Commit to database
345
+ db.session.commit()
346
+
347
+ log.info(f"Employee {empleado_id} processed successfully. " f"Net: {emp_calculo.salario_neto}")
348
+
349
+ return {
350
+ "empleado_id": empleado_id,
351
+ "salario_bruto": emp_calculo.salario_bruto,
352
+ "salario_neto": emp_calculo.salario_neto,
353
+ "total_deducciones": emp_calculo.total_deducciones,
354
+ "success": True,
355
+ }
356
+
357
+ except Exception as e:
358
+ log.error(f"Error processing employee {empleado_id}: {e}")
359
+ db.session.rollback()
360
+ return {
361
+ "empleado_id": empleado_id,
362
+ "success": False,
363
+ "error": str(e),
364
+ }
365
+
366
+
367
+ def process_payroll_parallel(
368
+ planilla_id: str,
369
+ periodo_inicio: str,
370
+ periodo_fin: str,
371
+ fecha_calculo: str | None = None,
372
+ usuario: str | None = None,
373
+ ) -> dict[str, bool | int | list[str]]:
374
+ """Process payroll for all employees in parallel (background task).
375
+
376
+ NOTE: This function now uses the same defensive mechanism as process_large_payroll
377
+ to ensure atomicity. If any employee processing fails, all changes are rolled back.
378
+
379
+ For true parallel processing with multiple workers, use process_large_payroll which
380
+ processes employees sequentially but provides better error handling and rollback.
381
+
382
+ Args:
383
+ planilla_id: Planilla ID (ULID string)
384
+ periodo_inicio: Start date (ISO format: YYYY-MM-DD)
385
+ periodo_fin: End date (ISO format: YYYY-MM-DD)
386
+ fecha_calculo: Calculation date (ISO format, optional)
387
+ usuario: Username executing the payroll (optional)
388
+
389
+ Returns:
390
+ Dictionary with processing results:
391
+ {
392
+ "success": bool,
393
+ "total_empleados": int,
394
+ "empleados_procesados": int,
395
+ "empleados_con_error": int,
396
+ "errores": list[str]
397
+ }
398
+ """
399
+ from coati_payroll.enums import NominaEstado
400
+
401
+ try:
402
+ log.info(f"Starting parallel payroll processing for planilla {planilla_id}")
403
+
404
+ # Load planilla
405
+ planilla = db.session.get(Planilla, planilla_id)
406
+ if not planilla:
407
+ return {
408
+ "success": False,
409
+ "error": "Planilla not found",
410
+ }
411
+
412
+ # Get all active employees
413
+ empleados = [pe.empleado for pe in planilla.planilla_empleados if pe.activo and pe.empleado.activo]
414
+
415
+ if not empleados:
416
+ return {
417
+ "success": False,
418
+ "error": "No active employees found",
419
+ }
420
+
421
+ # Create nomina record first
422
+ nomina = NominaModel(
423
+ planilla_id=planilla_id,
424
+ periodo_inicio=date.fromisoformat(periodo_inicio),
425
+ periodo_fin=date.fromisoformat(periodo_fin),
426
+ generado_por=usuario,
427
+ estado=NominaEstado.CALCULANDO,
428
+ total_bruto=Decimal("0.00"),
429
+ total_deducciones=Decimal("0.00"),
430
+ total_neto=Decimal("0.00"),
431
+ total_empleados=len(empleados),
432
+ empleados_procesados=0,
433
+ empleados_con_error=0,
434
+ procesamiento_en_background=True,
435
+ )
436
+ db.session.add(nomina)
437
+ db.session.commit()
438
+
439
+ # Use process_large_payroll which has the defensive rollback mechanism
440
+ # This ensures atomicity: if any employee fails, all changes are rolled back
441
+ result = process_large_payroll(
442
+ nomina_id=nomina.id,
443
+ planilla_id=planilla_id,
444
+ periodo_inicio=periodo_inicio,
445
+ periodo_fin=periodo_fin,
446
+ fecha_calculo=fecha_calculo,
447
+ usuario=usuario,
448
+ )
449
+
450
+ return result
451
+
452
+ except Exception as e:
453
+ log.error(f"Error processing parallel payroll: {e}")
454
+ return {
455
+ "success": False,
456
+ "error": str(e),
457
+ }
458
+
459
+
460
+ def process_large_payroll(
461
+ nomina_id: str,
462
+ planilla_id: str,
463
+ periodo_inicio: str,
464
+ periodo_fin: str,
465
+ fecha_calculo: str | None = None,
466
+ usuario: str | None = None,
467
+ ) -> dict[str, bool | int | list[str]]:
468
+ """Process large payroll in background with progress tracking.
469
+
470
+ This task processes a payroll for all employees sequentially,
471
+ updating progress in the database after each employee.
472
+ Designed for large payrolls (>100 employees) to provide
473
+ real-time feedback to users.
474
+
475
+ Args:
476
+ nomina_id: Nomina ID (ULID string)
477
+ planilla_id: Planilla ID (ULID string)
478
+ periodo_inicio: Start date (ISO format: YYYY-MM-DD)
479
+ periodo_fin: End date (ISO format: YYYY-MM-DD)
480
+ fecha_calculo: Calculation date (ISO format, optional)
481
+ usuario: Username executing the payroll (optional)
482
+
483
+ Returns:
484
+ Dictionary with processing results:
485
+ {
486
+ "success": bool,
487
+ "total_empleados": int,
488
+ "empleados_procesados": int,
489
+ "empleados_con_error": int,
490
+ "errores": list[str]
491
+ }
492
+ """
493
+ from coati_payroll.enums import NominaEstado
494
+
495
+ try:
496
+ log.info(f"Starting background processing for nomina {nomina_id}")
497
+
498
+ # Convert date strings to date objects
499
+ periodo_inicio_date = date.fromisoformat(periodo_inicio)
500
+ periodo_fin_date = date.fromisoformat(periodo_fin)
501
+ fecha_calculo_date = date.fromisoformat(fecha_calculo) if fecha_calculo else None
502
+
503
+ # Load nomina and planilla
504
+ nomina = db.session.get(NominaModel, nomina_id)
505
+ if not nomina:
506
+ log.error(f"Nomina {nomina_id} not found")
507
+ return {
508
+ "success": False,
509
+ "error": "Nomina not found",
510
+ }
511
+
512
+ # Load planilla with eager loading of tipo_planilla and moneda
513
+ planilla = db.session.execute(
514
+ db.select(Planilla)
515
+ .options(joinedload(Planilla.tipo_planilla), joinedload(Planilla.moneda))
516
+ .filter_by(id=planilla_id)
517
+ ).scalar_one_or_none()
518
+
519
+ if not planilla:
520
+ log.error(f"Planilla {planilla_id} not found")
521
+ nomina.estado = NominaEstado.ERROR
522
+ nomina.errores_calculo = {"error": "Planilla not found"}
523
+ db.session.commit()
524
+ return {
525
+ "success": False,
526
+ "error": "Planilla not found",
527
+ }
528
+
529
+ # Get all active employees
530
+ empleados = [pe.empleado for pe in planilla.planilla_empleados if pe.activo and pe.empleado.activo]
531
+
532
+ if not empleados:
533
+ log.warning(f"No active employees found for planilla {planilla_id}")
534
+ nomina.estado = NominaEstado.ERROR
535
+ nomina.errores_calculo = {"error": "No active employees found"}
536
+ db.session.commit()
537
+ return {
538
+ "success": False,
539
+ "error": "No active employees found",
540
+ }
541
+
542
+ # Initialize progress tracking
543
+ nomina.total_empleados = len(empleados)
544
+ nomina.empleados_procesados = 0
545
+ nomina.empleados_con_error = 0
546
+ nomina.errores_calculo = {}
547
+ nomina.log_procesamiento = []
548
+ db.session.commit()
549
+
550
+ # CRITICAL: Use savepoints for safer transaction management
551
+ # Process employees with periodic commits to reduce risk of losing all work
552
+ # If ANY employee fails, rollback ALL changes to maintain consistency
553
+ log_entries = []
554
+ BATCH_SIZE = 10 # Commit progress every N employees to reduce risk
555
+
556
+ try:
557
+ # Create initial savepoint for the entire operation
558
+ savepoint = db.session.begin_nested()
559
+
560
+ # Process each employee
561
+ for idx, empleado in enumerate(empleados, 1):
562
+ empleado_nombre = f"{empleado.primer_nombre} {empleado.primer_apellido}"
563
+
564
+ try:
565
+ # Create savepoint for this employee
566
+ emp_savepoint = db.session.begin_nested()
567
+
568
+ # Update current employee being processed (for progress tracking)
569
+ nomina.empleado_actual = empleado_nombre
570
+ log_entries.append(
571
+ {
572
+ "timestamp": datetime.now(timezone.utc).isoformat(),
573
+ "empleado": empleado_nombre,
574
+ "status": "processing",
575
+ "message": f"Calculando empleado {idx}/{len(empleados)}: {empleado_nombre}",
576
+ }
577
+ )
578
+ nomina.log_procesamiento = log_entries
579
+ # Commit progress updates separately (outside savepoint) so user can see progress
580
+ db.session.commit()
581
+
582
+ # Initialize engine for single employee
583
+ engine = NominaEngine(
584
+ planilla=planilla,
585
+ periodo_inicio=periodo_inicio_date,
586
+ periodo_fin=periodo_fin_date,
587
+ fecha_calculo=fecha_calculo_date,
588
+ usuario=usuario,
589
+ )
590
+ # Set nomina in engine so it can create related records
591
+ engine.nomina = nomina
592
+
593
+ # Process this employee (creates NominaEmpleado, NominaDetalle, etc.)
594
+ emp_calculo = engine._procesar_empleado(empleado)
595
+
596
+ # Commit this employee's savepoint (employee processed successfully)
597
+ emp_savepoint.commit()
598
+
599
+ # Update progress with success
600
+ nomina.empleados_procesados = idx
601
+ log_entries.append(
602
+ {
603
+ "timestamp": datetime.now(timezone.utc).isoformat(),
604
+ "empleado": empleado_nombre,
605
+ "status": "success",
606
+ "message": f"✓ Completado: {empleado_nombre} - Neto: {emp_calculo.salario_neto}",
607
+ }
608
+ )
609
+ nomina.log_procesamiento = log_entries
610
+
611
+ # Commit progress updates periodically to reduce risk
612
+ # This commits only the progress tracking, not the employee data
613
+ if idx % BATCH_SIZE == 0 or idx == len(empleados):
614
+ db.session.commit()
615
+ log.info(f"Progress committed: {idx}/{len(empleados)} employees processed")
616
+
617
+ log.info(f"Employee {empleado.id} processed successfully " f"({idx}/{nomina.total_empleados})")
618
+
619
+ except Exception as e:
620
+ # Rollback this employee's savepoint (undoes only this employee)
621
+ emp_savepoint.rollback()
622
+
623
+ # Re-raise to trigger outer rollback of entire operation
624
+ error_msg = str(e)
625
+ log.error(f"Error processing employee {empleado.id}: {error_msg}")
626
+ raise
627
+
628
+ # All employees processed successfully - commit the main savepoint
629
+ savepoint.commit()
630
+
631
+ # Calculate totals
632
+ total_bruto = sum(ne.salario_bruto for ne in nomina.nomina_empleados)
633
+ total_deducciones = sum(ne.total_deducciones for ne in nomina.nomina_empleados)
634
+ total_neto = sum(ne.salario_neto for ne in nomina.nomina_empleados)
635
+
636
+ nomina.total_bruto = total_bruto
637
+ nomina.total_deducciones = total_deducciones
638
+ nomina.total_neto = total_neto
639
+ nomina.estado = NominaEstado.GENERADO
640
+ nomina.errores_calculo = {}
641
+ nomina.empleado_actual = None # Clear current employee
642
+
643
+ # Final commit (this is smaller now since we've been committing progress)
644
+ db.session.commit()
645
+
646
+ log.info(f"All employees processed successfully for nomina {nomina_id}")
647
+
648
+ except Exception as e:
649
+ # CRITICAL: Rollback all changes if any employee fails
650
+ error_msg = str(e)
651
+ error_type = type(e).__name__
652
+
653
+ # Determine if error is recoverable (can be retried)
654
+ # Recoverable: database connection issues, temporary network problems, etc.
655
+ # Non-recoverable: validation errors, data integrity issues, etc.
656
+ is_recoverable = _is_recoverable_error(e)
657
+
658
+ log.error(
659
+ f"Critical error during payroll processing: {error_msg} "
660
+ f"(Type: {error_type}, Recoverable: {is_recoverable})"
661
+ )
662
+
663
+ # Rollback the main savepoint (undoes all employee processing)
664
+ try:
665
+ savepoint.rollback()
666
+ except Exception:
667
+ # If savepoint doesn't exist or already rolled back, do full rollback
668
+ db.session.rollback()
669
+
670
+ # Rollback any remaining data that might have been created
671
+ _rollback_nomina_data(nomina_id)
672
+
673
+ # Mark nomina as ERROR with detailed error information
674
+ nomina.estado = NominaEstado.ERROR
675
+ nomina.errores_calculo = {
676
+ "critical_error": error_msg,
677
+ "error_type": error_type,
678
+ "is_recoverable": is_recoverable,
679
+ "empleados_procesados_antes_fallo": nomina.empleados_procesados,
680
+ "timestamp": datetime.now(timezone.utc).isoformat(),
681
+ }
682
+ nomina.log_procesamiento = log_entries + [
683
+ {
684
+ "timestamp": datetime.now(timezone.utc).isoformat(),
685
+ "empleado": "SISTEMA",
686
+ "status": "error",
687
+ "message": (
688
+ f"✗ ERROR CRÍTICO: La nómina falló. Todos los cambios fueron revertidos. "
689
+ f"Error: {error_msg} "
690
+ f"(Puede reintentarse: {'Sí' if is_recoverable else 'No'})"
691
+ ),
692
+ }
693
+ ]
694
+ nomina.empleado_actual = None
695
+ db.session.commit()
696
+
697
+ # Re-raise to signal failure (queue system will handle retries if configured)
698
+ raise
699
+
700
+ log.info(
701
+ f"Background processing completed for nomina {nomina_id}. "
702
+ f"Processed: {nomina.empleados_procesados}/{nomina.total_empleados}, "
703
+ f"Errors: {nomina.empleados_con_error}"
704
+ )
705
+
706
+ return {
707
+ "success": True,
708
+ "total_empleados": nomina.total_empleados,
709
+ "empleados_procesados": nomina.empleados_procesados,
710
+ "empleados_con_error": nomina.empleados_con_error,
711
+ "errores": nomina.errores_calculo or {},
712
+ }
713
+
714
+ except Exception as e:
715
+ log.error(f"Critical error in background payroll processing: {e}")
716
+ try:
717
+ nomina = db.session.get(NominaModel, nomina_id)
718
+ if nomina:
719
+ nomina.estado = NominaEstado.ERROR
720
+ nomina.errores_calculo = {"critical_error": str(e)}
721
+ db.session.commit()
722
+ except Exception:
723
+ pass
724
+ return {
725
+ "success": False,
726
+ "error": str(e),
727
+ }
728
+
729
+
730
+ # Get retry configuration from environment
731
+ _retry_config = _get_payroll_retry_config()
732
+
733
+ # Register tasks with the queue driver
734
+ calculate_employee_payroll_task = queue.register_task(
735
+ calculate_employee_payroll,
736
+ name="calculate_employee_payroll",
737
+ max_retries=int(os.getenv("PAYROLL_EMPLOYEE_MAX_RETRIES", "3")),
738
+ min_backoff=int(os.getenv("PAYROLL_EMPLOYEE_MIN_BACKOFF_MS", "15000")), # 15 seconds
739
+ max_backoff=int(os.getenv("PAYROLL_EMPLOYEE_MAX_BACKOFF_MS", "3600000")), # 1 hour
740
+ )
741
+
742
+ process_payroll_parallel_task = queue.register_task(
743
+ process_payroll_parallel,
744
+ name="process_payroll_parallel",
745
+ max_retries=int(os.getenv("PAYROLL_PARALLEL_MAX_RETRIES", "2")),
746
+ min_backoff=int(os.getenv("PAYROLL_PARALLEL_MIN_BACKOFF_MS", "30000")), # 30 seconds
747
+ max_backoff=int(os.getenv("PAYROLL_PARALLEL_MAX_BACKOFF_MS", "7200000")), # 2 hours
748
+ )
749
+
750
+ process_large_payroll_task = queue.register_task(
751
+ process_large_payroll,
752
+ name="process_large_payroll",
753
+ max_retries=_retry_config["max_retries"],
754
+ min_backoff=_retry_config["min_backoff_ms"],
755
+ max_backoff=_retry_config["max_backoff_ms"],
756
+ )
757
+
758
+ retry_failed_nomina_task = queue.register_task(
759
+ retry_failed_nomina,
760
+ name="retry_failed_nomina",
761
+ max_retries=1, # Manual retry, no automatic retries needed
762
+ min_backoff=0,
763
+ max_backoff=0,
764
+ )