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,267 @@
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
+
15
+ """Liquidación engine orchestrator."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from datetime import date, timedelta
21
+ from decimal import Decimal
22
+
23
+ from sqlalchemy import select
24
+
25
+ from coati_payroll.enums import NominaEstado
26
+ from coati_payroll.model import (
27
+ ConfiguracionCalculos,
28
+ Empleado,
29
+ Liquidacion,
30
+ LiquidacionDetalle,
31
+ Nomina,
32
+ NominaEmpleado,
33
+ AdelantoAbono,
34
+ Adelanto,
35
+ db,
36
+ )
37
+ from coati_payroll.nomina_engine.repositories.config_repository import ConfigRepository
38
+ from coati_payroll.nomina_engine.processors.loan_processor import LoanProcessor
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LiquidacionResult:
43
+ liquidacion: Liquidacion | None
44
+ errors: list[str]
45
+ warnings: list[str]
46
+
47
+
48
+ class LiquidacionEngine:
49
+ """Engine for calculating employee termination settlements (liquidaciones)."""
50
+
51
+ def __init__(self, empleado: Empleado, fecha_calculo: date | None = None, usuario: str | None = None):
52
+ self.empleado = empleado
53
+ self.fecha_calculo = fecha_calculo or date.today()
54
+ self.usuario = usuario
55
+ self.errors: list[str] = []
56
+ self.warnings: list[str] = []
57
+
58
+ self._config_repo = ConfigRepository(db.session)
59
+
60
+ def _get_config(self) -> ConfiguracionCalculos:
61
+ return self._config_repo.get_for_empresa(self.empleado.empresa_id)
62
+
63
+ def determinar_ultimo_dia_pagado(self) -> date:
64
+ """Get the last day covered by the employee's last applied/paid payroll."""
65
+ stmt = (
66
+ select(Nomina.periodo_fin)
67
+ .join(NominaEmpleado, NominaEmpleado.nomina_id == Nomina.id)
68
+ .where(
69
+ NominaEmpleado.empleado_id == self.empleado.id,
70
+ Nomina.estado.in_([NominaEstado.APLICADO, NominaEstado.PAGADO]),
71
+ )
72
+ .order_by(Nomina.periodo_fin.desc())
73
+ .limit(1)
74
+ )
75
+
76
+ ultimo = db.session.execute(stmt).scalar_one_or_none()
77
+ if ultimo:
78
+ return ultimo
79
+
80
+ fecha_alta = self.empleado.fecha_alta
81
+ if not fecha_alta:
82
+ self.warnings.append("Empleado sin fecha de alta; usando fecha de cálculo como referencia.")
83
+ return self.fecha_calculo
84
+
85
+ return fecha_alta - timedelta(days=1)
86
+
87
+ def _get_factor_dias(self, config: ConfiguracionCalculos) -> int:
88
+ modo = (config.liquidacion_modo_dias or "calendario").strip().lower()
89
+ if modo == "laboral":
90
+ return int(config.liquidacion_factor_laboral or 28)
91
+ return int(config.liquidacion_factor_calendario or 30)
92
+
93
+ def calcular(self, liquidacion: Liquidacion) -> Liquidacion | None:
94
+ """Calculate a liquidacion record in-place."""
95
+ config = self._get_config()
96
+
97
+ ultimo_dia_pagado = self.determinar_ultimo_dia_pagado()
98
+ liquidacion.ultimo_dia_pagado = ultimo_dia_pagado
99
+ liquidacion.fecha_calculo = self.fecha_calculo
100
+
101
+ if self.fecha_calculo <= ultimo_dia_pagado:
102
+ liquidacion.dias_por_pagar = 0
103
+ self.warnings.append("La fecha de cálculo es menor o igual al último día pagado.")
104
+ else:
105
+ liquidacion.dias_por_pagar = (self.fecha_calculo - ultimo_dia_pagado).days
106
+
107
+ # Clear previous details (support recalculation)
108
+ liquidacion.detalles.clear()
109
+
110
+ # Income for pending days
111
+ factor_dias = self._get_factor_dias(config)
112
+ salario_mensual = Decimal(str(self.empleado.salario_base or 0))
113
+ if factor_dias <= 0:
114
+ self.errors.append("Factor de días inválido en configuración.")
115
+ return None
116
+
117
+ tasa_dia = (salario_mensual / Decimal(str(factor_dias))).quantize(Decimal("0.01"))
118
+ monto_dias = (tasa_dia * Decimal(str(liquidacion.dias_por_pagar))).quantize(Decimal("0.01"))
119
+
120
+ if liquidacion.dias_por_pagar > 0 and monto_dias > 0:
121
+ liquidacion.detalles.append(
122
+ LiquidacionDetalle(
123
+ tipo="ingreso",
124
+ codigo="DIAS_POR_PAGAR",
125
+ descripcion="Días por pagar",
126
+ monto=monto_dias,
127
+ orden=1,
128
+ )
129
+ )
130
+
131
+ # Apply pending loans/advances as deductions
132
+ saldo_disponible = monto_dias
133
+ loan_processor = LoanProcessor(
134
+ nomina=None,
135
+ fecha_calculo=self.fecha_calculo,
136
+ periodo_inicio=ultimo_dia_pagado,
137
+ periodo_fin=self.fecha_calculo,
138
+ liquidacion=liquidacion,
139
+ calcular_interes=False,
140
+ )
141
+
142
+ # Defaults match Planilla priorities; liquidation uses fixed priorities for now
143
+ deducciones = []
144
+ deducciones.extend(
145
+ loan_processor.process_loans(
146
+ empleado_id=self.empleado.id,
147
+ saldo_disponible=saldo_disponible,
148
+ aplicar_prestamos=True,
149
+ prioridad_prestamos=250,
150
+ )
151
+ )
152
+ for d in deducciones:
153
+ saldo_disponible -= d.monto
154
+
155
+ deducciones_adv = loan_processor.process_advances(
156
+ empleado_id=self.empleado.id,
157
+ saldo_disponible=saldo_disponible,
158
+ aplicar_adelantos=True,
159
+ prioridad_adelantos=251,
160
+ )
161
+ deducciones.extend(deducciones_adv)
162
+
163
+ orden = 1
164
+ total_deducciones = Decimal("0.00")
165
+ for item in deducciones:
166
+ orden += 1
167
+ total_deducciones += item.monto
168
+ liquidacion.detalles.append(
169
+ LiquidacionDetalle(
170
+ tipo="deduccion",
171
+ codigo=item.codigo,
172
+ descripcion=item.nombre,
173
+ monto=item.monto,
174
+ orden=orden,
175
+ )
176
+ )
177
+
178
+ total_bruto = monto_dias
179
+ total_neto = (total_bruto - total_deducciones).quantize(Decimal("0.01"))
180
+ liquidacion.total_bruto = total_bruto
181
+ liquidacion.total_deducciones = total_deducciones
182
+ liquidacion.total_neto = total_neto
183
+
184
+ liquidacion.errores_calculo = {"errors": self.errors} if self.errors else {}
185
+ liquidacion.advertencias_calculo = list(self.warnings)
186
+
187
+ return liquidacion
188
+
189
+
190
+ def recalcular_liquidacion(liquidacion_id: str, fecha_calculo: date | None = None, usuario: str | None = None):
191
+ """Recalculate an existing liquidacion.
192
+
193
+ - Removes existing details
194
+ - Reverts any AdelantoAbono records created by this liquidation
195
+ - Re-runs calculation
196
+ """
197
+ liquidacion = db.session.get(Liquidacion, liquidacion_id)
198
+ if not liquidacion:
199
+ return None, ["Liquidación no encontrada."], []
200
+
201
+ if liquidacion.estado != "borrador":
202
+ return None, ["Solo se pueden recalcular liquidaciones en borrador."], []
203
+
204
+ empleado = db.session.get(Empleado, liquidacion.empleado_id)
205
+ if not empleado:
206
+ return None, ["Empleado no encontrado."], []
207
+
208
+ # Revert loan/advance payments applied by this liquidation
209
+ abonos = (
210
+ db.session.execute(select(AdelantoAbono).where(AdelantoAbono.liquidacion_id == liquidacion.id)).scalars().all()
211
+ )
212
+
213
+ for abono in abonos:
214
+ adelanto = db.session.get(Adelanto, abono.adelanto_id)
215
+ if adelanto:
216
+ # Undo the payment (add back to saldo)
217
+ adelanto.saldo_pendiente = (
218
+ Decimal(str(adelanto.saldo_pendiente)) + Decimal(str(abono.monto_abonado))
219
+ ).quantize(Decimal("0.01"))
220
+ if adelanto.saldo_pendiente > 0 and adelanto.estado == "pagado":
221
+ adelanto.estado = "aprobado"
222
+ db.session.delete(abono)
223
+
224
+ # Remove existing details
225
+ liquidacion.detalles.clear()
226
+
227
+ engine = LiquidacionEngine(
228
+ empleado=empleado, fecha_calculo=fecha_calculo or liquidacion.fecha_calculo, usuario=usuario
229
+ )
230
+ calculated = engine.calcular(liquidacion)
231
+ if not calculated:
232
+ db.session.rollback()
233
+ return None, engine.errors, engine.warnings
234
+
235
+ db.session.commit()
236
+ return calculated, engine.errors, engine.warnings
237
+
238
+
239
+ def ejecutar_liquidacion(
240
+ empleado_id: str,
241
+ concepto_id: str | None,
242
+ fecha_calculo: date | None = None,
243
+ usuario: str | None = None,
244
+ ) -> tuple[Liquidacion | None, list[str], list[str]]:
245
+ """Convenience function to create and calculate a liquidacion."""
246
+ empleado = db.session.get(Empleado, empleado_id)
247
+ if not empleado:
248
+ return None, ["Empleado no encontrado."], []
249
+
250
+ liquidacion = Liquidacion(
251
+ empleado_id=empleado.id,
252
+ concepto_id=concepto_id,
253
+ fecha_calculo=fecha_calculo or date.today(),
254
+ estado="borrador",
255
+ )
256
+ db.session.add(liquidacion)
257
+ db.session.flush()
258
+
259
+ engine = LiquidacionEngine(empleado=empleado, fecha_calculo=fecha_calculo, usuario=usuario)
260
+ calculated = engine.calcular(liquidacion)
261
+
262
+ if not calculated:
263
+ db.session.rollback()
264
+ return None, engine.errors, engine.warnings
265
+
266
+ db.session.commit()
267
+ return calculated, engine.errors, engine.warnings
@@ -0,0 +1,165 @@
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
+ """Language configuration and caching for internationalization."""
15
+
16
+ from __future__ import annotations
17
+
18
+ # <-------------------------------------------------------------------------> #
19
+ # Standard library
20
+ # <-------------------------------------------------------------------------> #
21
+ from os import environ
22
+ from threading import Lock
23
+
24
+ # <-------------------------------------------------------------------------> #
25
+ # Third party libraries
26
+ # <-------------------------------------------------------------------------> #
27
+ from flask import current_app
28
+
29
+ # <-------------------------------------------------------------------------> #
30
+ # Local modules
31
+ # <-------------------------------------------------------------------------> #
32
+ from coati_payroll.log import log
33
+
34
+ # Supported languages
35
+ SUPPORTED_LANGUAGES = ["en", "es"]
36
+ DEFAULT_LANGUAGE = "en"
37
+
38
+ # Cache for language setting with thread-safe access
39
+ _language_cache = None
40
+ _cache_lock = Lock()
41
+
42
+
43
+ def get_language_from_db() -> str:
44
+ """Get the configured language from the database.
45
+
46
+ Returns the language code ('en' or 'es') from the global configuration table.
47
+ If no configuration exists, returns the default language.
48
+ Uses caching to avoid repeated database queries.
49
+
50
+ Returns:
51
+ str: Language code ('en' or 'es')
52
+ """
53
+ global _language_cache
54
+
55
+ # Check cache first (thread-safe)
56
+ with _cache_lock:
57
+ if _language_cache is not None:
58
+ return _language_cache
59
+
60
+ # Cache miss - query database
61
+ try:
62
+ from coati_payroll.model import ConfiguracionGlobal, db
63
+
64
+ with current_app.app_context():
65
+ config = db.session.execute(db.select(ConfiguracionGlobal)).scalar_one_or_none()
66
+
67
+ if config and config.idioma in SUPPORTED_LANGUAGES:
68
+ language = config.idioma
69
+ else:
70
+ language = DEFAULT_LANGUAGE
71
+
72
+ # Update cache (thread-safe)
73
+ with _cache_lock:
74
+ _language_cache = language
75
+
76
+ log.trace(f"Language loaded from database: {language}")
77
+ return language
78
+
79
+ except Exception as e:
80
+ log.warning(f"Error reading language from database: {e}")
81
+ return DEFAULT_LANGUAGE
82
+
83
+
84
+ def set_language_in_db(language: str) -> None:
85
+ """Set the configured language in the database.
86
+
87
+ Updates the language setting in the global configuration table and
88
+ invalidates the cache to ensure the change is immediately reflected.
89
+
90
+ Args:
91
+ language: Language code ('en' or 'es')
92
+
93
+ Raises:
94
+ ValueError: If language is not supported
95
+ """
96
+ if language not in SUPPORTED_LANGUAGES:
97
+ raise ValueError(f"Unsupported language: {language}. Must be one of {SUPPORTED_LANGUAGES}")
98
+
99
+ from coati_payroll.model import ConfiguracionGlobal, db
100
+
101
+ with current_app.app_context():
102
+ config = db.session.execute(db.select(ConfiguracionGlobal)).scalar_one_or_none()
103
+
104
+ if config:
105
+ config.idioma = language
106
+ else:
107
+ # Create new configuration record
108
+ config = ConfiguracionGlobal()
109
+ config.idioma = language
110
+ db.session.add(config)
111
+
112
+ db.session.commit()
113
+
114
+ # Invalidate cache to force reload on next access
115
+ invalidate_language_cache()
116
+
117
+ log.info(f"Language updated to: {language}")
118
+
119
+
120
+ def invalidate_language_cache() -> None:
121
+ """Invalidate the language cache.
122
+
123
+ Forces the next call to get_language_from_db() to query the database.
124
+ Call this after updating the language setting.
125
+ """
126
+ global _language_cache
127
+
128
+ with _cache_lock:
129
+ _language_cache = None
130
+
131
+ log.trace("Language cache invalidated")
132
+
133
+
134
+ def initialize_language_from_env() -> None:
135
+ """Initialize language from COATI_LANG environment variable.
136
+
137
+ Called during application startup to set the initial language from
138
+ the environment variable if provided. Only updates the database if
139
+ no configuration exists yet.
140
+ """
141
+ env_lang = environ.get("COATI_LANG", "").strip().lower()
142
+
143
+ if not env_lang:
144
+ return
145
+
146
+ if env_lang not in SUPPORTED_LANGUAGES:
147
+ log.warning(f"Invalid COATI_LANG value: {env_lang}. " f"Must be one of {SUPPORTED_LANGUAGES}. Using default.")
148
+ return
149
+
150
+ try:
151
+ from coati_payroll.model import ConfiguracionGlobal, db
152
+
153
+ with current_app.app_context():
154
+ config = db.session.execute(db.select(ConfiguracionGlobal)).scalar_one_or_none()
155
+
156
+ # Only set from environment if no config exists yet
157
+ if not config:
158
+ config = ConfiguracionGlobal()
159
+ config.idioma = env_lang
160
+ db.session.add(config)
161
+ db.session.commit()
162
+ log.info(f"Language initialized from COATI_LANG: {env_lang}")
163
+
164
+ except Exception as e:
165
+ log.warning(f"Error initializing language from environment: {e}")
coati_payroll/log.py ADDED
@@ -0,0 +1,138 @@
1
+ # Copyright 2022 - 2024 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
+ #
15
+ """Configuración de logs."""
16
+
17
+ from __future__ import annotations
18
+
19
+ # <-------------------------------------------------------------------------> #
20
+ # Standard library
21
+ # <-------------------------------------------------------------------------> #
22
+ import logging
23
+ from os import environ
24
+ from sys import stdout
25
+
26
+ # <-------------------------------------------------------------------------> #
27
+ # Third-party libraries
28
+ # <-------------------------------------------------------------------------> #
29
+
30
+ # <-------------------------------------------------------------------------> #
31
+ # Local modules
32
+ # <-------------------------------------------------------------------------> #
33
+
34
+ # Definir nivel TRACE
35
+ TRACE_LEVEL_NUM = 5
36
+ logging.addLevelName(TRACE_LEVEL_NUM, "TRACE")
37
+
38
+
39
+ # Método adicional para usar logger.trace(...)
40
+ def trace(self, message, *args, **kwargs):
41
+ """Log a message with TRACE level."""
42
+ if self.isEnabledFor(TRACE_LEVEL_NUM):
43
+ self._log(TRACE_LEVEL_NUM, message, args, **kwargs)
44
+
45
+
46
+ logging.Logger.trace = trace
47
+
48
+ # Configurar nivel desde variable de entorno (default: INFO)
49
+ log_level_str = environ.get("LOG_LEVEL", "INFO").upper()
50
+
51
+ # Soporte de niveles personalizados
52
+ custom_levels = {
53
+ "TRACE": TRACE_LEVEL_NUM,
54
+ "DEBUG": logging.DEBUG,
55
+ "INFO": logging.INFO,
56
+ "WARNING": logging.WARNING,
57
+ "ERROR": logging.ERROR,
58
+ "CRITICAL": logging.CRITICAL,
59
+ }
60
+
61
+ # Nivel numérico, default INFO si no coincide
62
+ numeric_level = custom_levels.get(log_level_str, logging.INFO)
63
+
64
+ # Configurar logger raíz
65
+ root_logger = logging.getLogger("now_lms")
66
+ root_logger.setLevel(numeric_level)
67
+
68
+ # Handler solo para stdout
69
+ console_handler = logging.StreamHandler(stdout)
70
+ console_handler.setLevel(numeric_level)
71
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s: %(message)s")
72
+ console_handler.setFormatter(formatter)
73
+ root_logger.addHandler(console_handler)
74
+
75
+ # Configurar logger de Flask y Werkzeug al mismo nivel
76
+ logging.getLogger("flask").setLevel(numeric_level)
77
+ logging.getLogger("werkzeug").setLevel(numeric_level)
78
+
79
+ # Configurar logger de SQLAlchemy al nivel WARNING
80
+ logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
81
+
82
+ LOG_LEVEL = root_logger.getEffectiveLevel()
83
+
84
+ log = root_logger
85
+ logger = root_logger
86
+
87
+
88
+ # Cached helper to avoid repeated debug/level checks on every trace call
89
+ _TRACE_ACTIVE: bool | None = None
90
+
91
+
92
+ def _compute_trace_active(debug_flag: bool | None = None) -> bool:
93
+ """Compute whether TRACE logging should be emitted.
94
+
95
+ Prefers an explicit debug_flag, then Flask's current_app.debug (if available),
96
+ then FLASK_DEBUG/FLASK_ENV environment hints. Also verifies the logger is
97
+ actually enabled for TRACE level.
98
+ """
99
+
100
+ # Determine debug flag
101
+ if debug_flag is None:
102
+ try:
103
+ from flask import current_app
104
+
105
+ debug_flag = bool(getattr(current_app, "debug", False))
106
+ except Exception:
107
+ debug_flag = False
108
+
109
+ if not debug_flag:
110
+ debug_env = environ.get("FLASK_DEBUG") or environ.get("FLASK_ENV")
111
+ if debug_env:
112
+ debug_flag = str(debug_env).lower() in {
113
+ "1",
114
+ "true",
115
+ "yes",
116
+ "on",
117
+ "development",
118
+ "dev",
119
+ "debug",
120
+ }
121
+
122
+ try:
123
+ return bool(debug_flag) and log.isEnabledFor(TRACE_LEVEL_NUM)
124
+ except Exception:
125
+ return False
126
+
127
+
128
+ def is_trace_enabled(*, force_refresh: bool = False, debug_flag: bool | None = None) -> bool:
129
+ """Return cached TRACE-enabled flag, computing once unless refreshed.
130
+
131
+ This keeps per-log-call overhead minimal while allowing an explicit refresh
132
+ if runtime configuration changes.
133
+ """
134
+
135
+ global _TRACE_ACTIVE
136
+ if force_refresh or _TRACE_ACTIVE is None:
137
+ _TRACE_ACTIVE = _compute_trace_active(debug_flag)
138
+ return _TRACE_ACTIVE