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.
- coati_payroll/__init__.py +415 -0
- coati_payroll/app.py +95 -0
- coati_payroll/audit_helpers.py +904 -0
- coati_payroll/auth.py +123 -0
- coati_payroll/cli.py +1318 -0
- coati_payroll/config.py +219 -0
- coati_payroll/demo_data.py +813 -0
- coati_payroll/enums.py +278 -0
- coati_payroll/forms.py +1769 -0
- coati_payroll/formula_engine/__init__.py +81 -0
- coati_payroll/formula_engine/ast/__init__.py +110 -0
- coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
- coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
- coati_payroll/formula_engine/ast/safe_operators.py +131 -0
- coati_payroll/formula_engine/ast/type_converter.py +172 -0
- coati_payroll/formula_engine/data_sources.py +752 -0
- coati_payroll/formula_engine/engine.py +247 -0
- coati_payroll/formula_engine/exceptions.py +52 -0
- coati_payroll/formula_engine/execution/__init__.py +24 -0
- coati_payroll/formula_engine/execution/execution_context.py +52 -0
- coati_payroll/formula_engine/execution/step_executor.py +62 -0
- coati_payroll/formula_engine/execution/variable_store.py +59 -0
- coati_payroll/formula_engine/novelty_codes.py +206 -0
- coati_payroll/formula_engine/results/__init__.py +20 -0
- coati_payroll/formula_engine/results/execution_result.py +59 -0
- coati_payroll/formula_engine/steps/__init__.py +30 -0
- coati_payroll/formula_engine/steps/assignment_step.py +71 -0
- coati_payroll/formula_engine/steps/base_step.py +48 -0
- coati_payroll/formula_engine/steps/calculation_step.py +42 -0
- coati_payroll/formula_engine/steps/conditional_step.py +122 -0
- coati_payroll/formula_engine/steps/step_factory.py +58 -0
- coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
- coati_payroll/formula_engine/tables/__init__.py +24 -0
- coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
- coati_payroll/formula_engine/tables/table_lookup.py +161 -0
- coati_payroll/formula_engine/tables/tax_table.py +32 -0
- coati_payroll/formula_engine/validation/__init__.py +24 -0
- coati_payroll/formula_engine/validation/schema_validator.py +37 -0
- coati_payroll/formula_engine/validation/security_validator.py +52 -0
- coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
- coati_payroll/formula_engine_examples.py +153 -0
- coati_payroll/i18n.py +54 -0
- coati_payroll/initial_data.py +613 -0
- coati_payroll/interes_engine.py +450 -0
- coati_payroll/liquidacion_engine/__init__.py +25 -0
- coati_payroll/liquidacion_engine/engine.py +267 -0
- coati_payroll/locale_config.py +165 -0
- coati_payroll/log.py +138 -0
- coati_payroll/model.py +2410 -0
- coati_payroll/nomina_engine/__init__.py +87 -0
- coati_payroll/nomina_engine/calculators/__init__.py +30 -0
- coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
- coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
- coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
- coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
- coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
- coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
- coati_payroll/nomina_engine/domain/__init__.py +27 -0
- coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
- coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
- coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
- coati_payroll/nomina_engine/engine.py +188 -0
- coati_payroll/nomina_engine/processors/__init__.py +28 -0
- coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
- coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
- coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
- coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
- coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
- coati_payroll/nomina_engine/repositories/__init__.py +32 -0
- coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
- coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
- coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
- coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
- coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
- coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
- coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
- coati_payroll/nomina_engine/results/__init__.py +24 -0
- coati_payroll/nomina_engine/results/error_result.py +28 -0
- coati_payroll/nomina_engine/results/payroll_result.py +53 -0
- coati_payroll/nomina_engine/results/validation_result.py +39 -0
- coati_payroll/nomina_engine/services/__init__.py +22 -0
- coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
- coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
- coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
- coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
- coati_payroll/nomina_engine/validators/__init__.py +31 -0
- coati_payroll/nomina_engine/validators/base_validator.py +48 -0
- coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
- coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
- coati_payroll/nomina_engine/validators/period_validator.py +44 -0
- coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
- coati_payroll/plugin_manager.py +176 -0
- coati_payroll/queue/__init__.py +33 -0
- coati_payroll/queue/driver.py +127 -0
- coati_payroll/queue/drivers/__init__.py +22 -0
- coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
- coati_payroll/queue/drivers/huey_driver.py +390 -0
- coati_payroll/queue/drivers/noop_driver.py +54 -0
- coati_payroll/queue/selector.py +121 -0
- coati_payroll/queue/tasks.py +764 -0
- coati_payroll/rate_limiting.py +83 -0
- coati_payroll/rbac.py +183 -0
- coati_payroll/report_engine.py +512 -0
- coati_payroll/report_export.py +208 -0
- coati_payroll/schema_validator.py +167 -0
- coati_payroll/security.py +77 -0
- coati_payroll/static/styles.css +1044 -0
- coati_payroll/system_reports.py +573 -0
- coati_payroll/templates/auth/login.html +189 -0
- coati_payroll/templates/base.html +283 -0
- coati_payroll/templates/index.html +227 -0
- coati_payroll/templates/macros.html +146 -0
- coati_payroll/templates/modules/calculation_rule/form.html +78 -0
- coati_payroll/templates/modules/calculation_rule/index.html +102 -0
- coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
- coati_payroll/templates/modules/config_calculos/index.html +44 -0
- coati_payroll/templates/modules/configuracion/index.html +90 -0
- coati_payroll/templates/modules/currency/form.html +47 -0
- coati_payroll/templates/modules/currency/index.html +64 -0
- coati_payroll/templates/modules/custom_field/form.html +62 -0
- coati_payroll/templates/modules/custom_field/index.html +78 -0
- coati_payroll/templates/modules/deduccion/form.html +1 -0
- coati_payroll/templates/modules/deduccion/index.html +1 -0
- coati_payroll/templates/modules/employee/form.html +254 -0
- coati_payroll/templates/modules/employee/index.html +76 -0
- coati_payroll/templates/modules/empresa/form.html +74 -0
- coati_payroll/templates/modules/empresa/index.html +71 -0
- coati_payroll/templates/modules/exchange_rate/form.html +47 -0
- coati_payroll/templates/modules/exchange_rate/import.html +93 -0
- coati_payroll/templates/modules/exchange_rate/index.html +114 -0
- coati_payroll/templates/modules/liquidacion/index.html +58 -0
- coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
- coati_payroll/templates/modules/liquidacion/ver.html +91 -0
- coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
- coati_payroll/templates/modules/percepcion/form.html +1 -0
- coati_payroll/templates/modules/percepcion/index.html +1 -0
- coati_payroll/templates/modules/planilla/config.html +190 -0
- coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
- coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
- coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
- coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
- coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
- coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
- coati_payroll/templates/modules/planilla/form.html +197 -0
- coati_payroll/templates/modules/planilla/index.html +144 -0
- coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
- coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
- coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
- coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
- coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
- coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
- coati_payroll/templates/modules/plugins/index.html +71 -0
- coati_payroll/templates/modules/prestacion/form.html +1 -0
- coati_payroll/templates/modules/prestacion/index.html +1 -0
- coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
- coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
- coati_payroll/templates/modules/prestamo/approve.html +156 -0
- coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
- coati_payroll/templates/modules/prestamo/detail.html +443 -0
- coati_payroll/templates/modules/prestamo/form.html +203 -0
- coati_payroll/templates/modules/prestamo/index.html +150 -0
- coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
- coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
- coati_payroll/templates/modules/report/admin_index.html +125 -0
- coati_payroll/templates/modules/report/detail.html +129 -0
- coati_payroll/templates/modules/report/execute.html +266 -0
- coati_payroll/templates/modules/report/index.html +95 -0
- coati_payroll/templates/modules/report/permissions.html +64 -0
- coati_payroll/templates/modules/settings/index.html +274 -0
- coati_payroll/templates/modules/shared/concept_form.html +201 -0
- coati_payroll/templates/modules/shared/concept_index.html +145 -0
- coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
- coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
- coati_payroll/templates/modules/user/form.html +65 -0
- coati_payroll/templates/modules/user/index.html +76 -0
- coati_payroll/templates/modules/user/profile.html +81 -0
- coati_payroll/templates/modules/vacation/account_detail.html +149 -0
- coati_payroll/templates/modules/vacation/account_form.html +52 -0
- coati_payroll/templates/modules/vacation/account_index.html +68 -0
- coati_payroll/templates/modules/vacation/dashboard.html +156 -0
- coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
- coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
- coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
- coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
- coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
- coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
- coati_payroll/templates/modules/vacation/policy_form.html +152 -0
- coati_payroll/templates/modules/vacation/policy_index.html +79 -0
- coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
- coati_payroll/vacation_service.py +451 -0
- coati_payroll/version.py +18 -0
- coati_payroll/vistas/__init__.py +64 -0
- coati_payroll/vistas/calculation_rule.py +307 -0
- coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
- coati_payroll/vistas/config_calculos.py +72 -0
- coati_payroll/vistas/configuracion.py +87 -0
- coati_payroll/vistas/constants.py +17 -0
- coati_payroll/vistas/currency.py +112 -0
- coati_payroll/vistas/custom_field.py +120 -0
- coati_payroll/vistas/employee.py +305 -0
- coati_payroll/vistas/empresa.py +153 -0
- coati_payroll/vistas/exchange_rate.py +341 -0
- coati_payroll/vistas/liquidacion.py +205 -0
- coati_payroll/vistas/payroll_concepts.py +580 -0
- coati_payroll/vistas/planilla/__init__.py +38 -0
- coati_payroll/vistas/planilla/association_routes.py +238 -0
- coati_payroll/vistas/planilla/config_routes.py +158 -0
- coati_payroll/vistas/planilla/export_routes.py +175 -0
- coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
- coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
- coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
- coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
- coati_payroll/vistas/planilla/nomina_routes.py +488 -0
- coati_payroll/vistas/planilla/novedad_routes.py +227 -0
- coati_payroll/vistas/planilla/routes.py +145 -0
- coati_payroll/vistas/planilla/services/__init__.py +26 -0
- coati_payroll/vistas/planilla/services/export_service.py +687 -0
- coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
- coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
- coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
- coati_payroll/vistas/planilla/validators/__init__.py +18 -0
- coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
- coati_payroll/vistas/plugins.py +45 -0
- coati_payroll/vistas/prestacion.py +272 -0
- coati_payroll/vistas/prestamo.py +808 -0
- coati_payroll/vistas/report.py +432 -0
- coati_payroll/vistas/settings.py +29 -0
- coati_payroll/vistas/tipo_planilla.py +134 -0
- coati_payroll/vistas/user.py +172 -0
- coati_payroll/vistas/vacation.py +1045 -0
- coati_payroll-0.0.2.dist-info/LICENSE +201 -0
- coati_payroll-0.0.2.dist-info/METADATA +581 -0
- coati_payroll-0.0.2.dist-info/RECORD +243 -0
- coati_payroll-0.0.2.dist-info/WHEEL +5 -0
- coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|