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,341 @@
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
+ """Exchange Rate CRUD routes."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import date, datetime
19
+ from decimal import Decimal
20
+
21
+ from flask import Blueprint, flash, redirect, render_template, request, url_for
22
+ from flask_login import current_user
23
+ from openpyxl import load_workbook
24
+
25
+ from coati_payroll.forms import ExchangeRateForm
26
+ from coati_payroll.i18n import _
27
+ from coati_payroll.rbac import require_read_access, require_write_access
28
+ from coati_payroll.model import Moneda, TipoCambio, db
29
+ from coati_payroll.vistas.constants import PER_PAGE
30
+
31
+ exchange_rate_bp = Blueprint("exchange_rate", __name__, url_prefix="/exchange_rate")
32
+
33
+
34
+ def get_currency_choices():
35
+ """Get list of currencies for select fields."""
36
+ currencies = db.session.execute(db.select(Moneda).filter_by(activo=True).order_by(Moneda.codigo)).scalars().all()
37
+ return [(c.id, f"{c.codigo} - {c.nombre}") for c in currencies]
38
+
39
+
40
+ @exchange_rate_bp.route("/")
41
+ @require_read_access()
42
+ def index():
43
+ """List all exchange rates with pagination and filters."""
44
+ page = request.args.get("page", 1, type=int)
45
+
46
+ # Get filter parameters
47
+ fecha_desde = request.args.get("fecha_desde", type=str)
48
+ fecha_hasta = request.args.get("fecha_hasta", type=str)
49
+ moneda_origen_id = request.args.get("moneda_origen_id", type=str) if request.args.get("moneda_origen_id") else None
50
+ moneda_destino_id = (
51
+ request.args.get("moneda_destino_id", type=str) if request.args.get("moneda_destino_id") else None
52
+ )
53
+
54
+ # Build query with filters
55
+ query = db.select(TipoCambio)
56
+
57
+ if fecha_desde:
58
+ query = query.filter(TipoCambio.fecha >= fecha_desde)
59
+ if fecha_hasta:
60
+ query = query.filter(TipoCambio.fecha <= fecha_hasta)
61
+ if moneda_origen_id:
62
+ query = query.filter(TipoCambio.moneda_origen_id == moneda_origen_id)
63
+ if moneda_destino_id:
64
+ query = query.filter(TipoCambio.moneda_destino_id == moneda_destino_id)
65
+
66
+ query = query.order_by(TipoCambio.fecha.desc())
67
+
68
+ pagination = db.paginate(
69
+ query,
70
+ page=page,
71
+ per_page=PER_PAGE,
72
+ error_out=False,
73
+ )
74
+
75
+ # Get currencies for filter dropdowns
76
+ currencies = get_currency_choices()
77
+
78
+ return render_template(
79
+ "modules/exchange_rate/index.html",
80
+ exchange_rates=pagination.items,
81
+ pagination=pagination,
82
+ currencies=currencies,
83
+ fecha_desde=fecha_desde,
84
+ fecha_hasta=fecha_hasta,
85
+ moneda_origen_id=moneda_origen_id,
86
+ moneda_destino_id=moneda_destino_id,
87
+ )
88
+
89
+
90
+ @exchange_rate_bp.route("/new", methods=["GET", "POST"])
91
+ @require_write_access()
92
+ def new():
93
+ """Create a new exchange rate."""
94
+ form = ExchangeRateForm()
95
+ form.moneda_origen_id.choices = [("", _("Seleccionar..."))] + get_currency_choices()
96
+ form.moneda_destino_id.choices = [("", _("Seleccionar..."))] + get_currency_choices()
97
+
98
+ if form.validate_on_submit():
99
+ exchange_rate = TipoCambio()
100
+ exchange_rate.fecha = form.fecha.data
101
+ exchange_rate.moneda_origen_id = form.moneda_origen_id.data
102
+ exchange_rate.moneda_destino_id = form.moneda_destino_id.data
103
+ exchange_rate.tasa = form.tasa.data
104
+ exchange_rate.creado_por = current_user.usuario
105
+
106
+ db.session.add(exchange_rate)
107
+ db.session.commit()
108
+ flash(_("Tipo de cambio creado exitosamente."), "success")
109
+ return redirect(url_for("exchange_rate.index"))
110
+
111
+ # Default date to today
112
+ if not form.fecha.data:
113
+ form.fecha.data = date.today()
114
+
115
+ return render_template("modules/exchange_rate/form.html", form=form, title=_("Nuevo Tipo de Cambio"))
116
+
117
+
118
+ @exchange_rate_bp.route("/edit/<string:id>", methods=["GET", "POST"])
119
+ @require_write_access()
120
+ def edit(id: str):
121
+ """Edit an existing exchange rate."""
122
+ exchange_rate = db.session.get(TipoCambio, id)
123
+ if not exchange_rate:
124
+ flash(_("Tipo de cambio no encontrado."), "error")
125
+ return redirect(url_for("exchange_rate.index"))
126
+
127
+ form = ExchangeRateForm(obj=exchange_rate)
128
+ form.moneda_origen_id.choices = [("", _("Seleccionar..."))] + get_currency_choices()
129
+ form.moneda_destino_id.choices = [("", _("Seleccionar..."))] + get_currency_choices()
130
+
131
+ if form.validate_on_submit():
132
+ exchange_rate.fecha = form.fecha.data
133
+ exchange_rate.moneda_origen_id = form.moneda_origen_id.data
134
+ exchange_rate.moneda_destino_id = form.moneda_destino_id.data
135
+ exchange_rate.tasa = form.tasa.data
136
+ exchange_rate.modificado_por = current_user.usuario
137
+
138
+ db.session.commit()
139
+ flash(_("Tipo de cambio actualizado exitosamente."), "success")
140
+ return redirect(url_for("exchange_rate.index"))
141
+
142
+ return render_template(
143
+ "modules/exchange_rate/form.html",
144
+ form=form,
145
+ title=_("Editar Tipo de Cambio"),
146
+ exchange_rate=exchange_rate,
147
+ )
148
+
149
+
150
+ @exchange_rate_bp.route("/delete/<string:id>", methods=["POST"])
151
+ @require_write_access()
152
+ def delete(id: str):
153
+ """Delete an exchange rate."""
154
+ exchange_rate = db.session.get(TipoCambio, id)
155
+ if not exchange_rate:
156
+ flash(_("Tipo de cambio no encontrado."), "error")
157
+ return redirect(url_for("exchange_rate.index"))
158
+
159
+ db.session.delete(exchange_rate)
160
+ db.session.commit()
161
+ flash(_("Tipo de cambio eliminado exitosamente."), "success")
162
+ return redirect(url_for("exchange_rate.index"))
163
+
164
+
165
+ # Constants for Excel import
166
+ EXPECTED_COLUMNS = 4
167
+ MAX_ERRORS_DISPLAYED = 10
168
+
169
+
170
+ @exchange_rate_bp.route("/import", methods=["GET", "POST"])
171
+ @require_write_access()
172
+ def import_excel():
173
+ """Import exchange rates from Excel file."""
174
+ if request.method == "GET":
175
+ return render_template("modules/exchange_rate/import.html")
176
+
177
+ # Check if file is in request
178
+ if "file" not in request.files:
179
+ flash(_("No se seleccionó ningún archivo."), "error")
180
+ return redirect(url_for("exchange_rate.import_excel"))
181
+
182
+ file = request.files["file"]
183
+
184
+ # Check if file has a name
185
+ if file.filename == "":
186
+ flash(_("No se seleccionó ningún archivo."), "error")
187
+ return redirect(url_for("exchange_rate.import_excel"))
188
+
189
+ # Check if file is Excel
190
+ if not file.filename.lower().endswith((".xlsx", ".xls")):
191
+ flash(_("El archivo debe ser un archivo Excel (.xlsx o .xls)."), "error")
192
+ return redirect(url_for("exchange_rate.import_excel"))
193
+
194
+ try:
195
+ # Load the workbook
196
+ workbook = load_workbook(file, data_only=True)
197
+ sheet = workbook.active
198
+
199
+ # Track statistics
200
+ imported_count = 0
201
+ updated_count = 0
202
+ error_count = 0
203
+ errors = []
204
+
205
+ # Get all active currencies for lookup
206
+ currencies = db.session.execute(db.select(Moneda).filter_by(activo=True)).scalars().all()
207
+ currency_map = {c.codigo.upper(): c for c in currencies}
208
+
209
+ # Process rows (skip header)
210
+ for row_idx, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
211
+ if not row or not any(row): # Skip empty rows
212
+ continue
213
+
214
+ try:
215
+ # Expected columns: Fecha | Moneda Base | Moneda Destino | Tipo de Cambio
216
+ if len(row) < EXPECTED_COLUMNS:
217
+ errors.append(
218
+ _("Fila {}: formato incorrecto, se esperan {} columnas.").format(row_idx, EXPECTED_COLUMNS)
219
+ )
220
+ error_count += 1
221
+ continue
222
+
223
+ fecha_val, moneda_origen_codigo, moneda_destino_codigo, tasa_val = row[0], row[1], row[2], row[3]
224
+
225
+ # Validate fecha
226
+ if isinstance(fecha_val, datetime):
227
+ fecha = fecha_val.date()
228
+ elif isinstance(fecha_val, date):
229
+ fecha = fecha_val
230
+ elif isinstance(fecha_val, str):
231
+ try:
232
+ fecha = datetime.strptime(fecha_val, "%Y-%m-%d").date()
233
+ except ValueError:
234
+ try:
235
+ fecha = datetime.strptime(fecha_val, "%d/%m/%Y").date()
236
+ except ValueError:
237
+ errors.append(_("Fila {}: fecha inválida '{}'.").format(row_idx, fecha_val))
238
+ error_count += 1
239
+ continue
240
+ else:
241
+ errors.append(_("Fila {}: fecha inválida.").format(row_idx))
242
+ error_count += 1
243
+ continue
244
+
245
+ # Validate moneda_origen
246
+ if not moneda_origen_codigo:
247
+ errors.append(_("Fila {}: moneda origen vacía.").format(row_idx))
248
+ error_count += 1
249
+ continue
250
+
251
+ moneda_origen_key = str(moneda_origen_codigo).strip().upper()
252
+ moneda_origen = currency_map.get(moneda_origen_key)
253
+ if not moneda_origen:
254
+ errors.append(_("Fila {}: moneda origen '{}' no encontrada.").format(row_idx, moneda_origen_codigo))
255
+ error_count += 1
256
+ continue
257
+
258
+ # Validate moneda_destino
259
+ if not moneda_destino_codigo:
260
+ errors.append(_("Fila {}: moneda destino vacía.").format(row_idx))
261
+ error_count += 1
262
+ continue
263
+
264
+ moneda_destino_key = str(moneda_destino_codigo).strip().upper()
265
+ moneda_destino = currency_map.get(moneda_destino_key)
266
+ if not moneda_destino:
267
+ errors.append(
268
+ _("Fila {}: moneda destino '{}' no encontrada.").format(row_idx, moneda_destino_codigo)
269
+ )
270
+ error_count += 1
271
+ continue
272
+
273
+ # Validate tasa
274
+ try:
275
+ if isinstance(tasa_val, (int, float)):
276
+ tasa = Decimal(str(tasa_val))
277
+ elif isinstance(tasa_val, str):
278
+ tasa = Decimal(tasa_val.strip())
279
+ else:
280
+ tasa = Decimal(str(tasa_val))
281
+
282
+ if tasa <= 0:
283
+ errors.append(_("Fila {}: tasa debe ser mayor que cero.").format(row_idx))
284
+ error_count += 1
285
+ continue
286
+ except (ValueError, TypeError):
287
+ errors.append(_("Fila {}: tasa inválida '{}'.").format(row_idx, tasa_val))
288
+ error_count += 1
289
+ continue
290
+
291
+ # Check if exchange rate already exists
292
+ existing = db.session.execute(
293
+ db.select(TipoCambio).filter_by(
294
+ fecha=fecha, moneda_origen_id=moneda_origen.id, moneda_destino_id=moneda_destino.id
295
+ )
296
+ ).scalar_one_or_none()
297
+
298
+ if existing:
299
+ # Update existing
300
+ existing.tasa = tasa
301
+ existing.modificado_por = current_user.usuario
302
+ updated_count += 1
303
+ else:
304
+ # Create new
305
+ exchange_rate = TipoCambio()
306
+ exchange_rate.fecha = fecha
307
+ exchange_rate.moneda_origen_id = moneda_origen.id
308
+ exchange_rate.moneda_destino_id = moneda_destino.id
309
+ exchange_rate.tasa = tasa
310
+ exchange_rate.creado_por = current_user.usuario
311
+ db.session.add(exchange_rate)
312
+ imported_count += 1
313
+
314
+ except Exception as e:
315
+ errors.append(_("Fila {}: error inesperado - {}.").format(row_idx, str(e)))
316
+ error_count += 1
317
+ continue
318
+
319
+ # Commit all changes
320
+ db.session.commit()
321
+
322
+ # Show results
323
+ if imported_count > 0 or updated_count > 0:
324
+ flash(
325
+ _("Importación completada: {} creados, {} actualizados.").format(imported_count, updated_count),
326
+ "success",
327
+ )
328
+
329
+ if error_count > 0:
330
+ flash(_("{} errores encontrados durante la importación.").format(error_count), "warning")
331
+ for error in errors[:MAX_ERRORS_DISPLAYED]:
332
+ flash(error, "error")
333
+ if len(errors) > MAX_ERRORS_DISPLAYED:
334
+ flash(_("... y {} errores más.").format(len(errors) - MAX_ERRORS_DISPLAYED), "error")
335
+
336
+ return redirect(url_for("exchange_rate.index"))
337
+
338
+ except Exception as e:
339
+ db.session.rollback()
340
+ flash(_("Error al procesar el archivo: {}.").format(str(e)), "error")
341
+ return redirect(url_for("exchange_rate.import_excel"))
@@ -0,0 +1,205 @@
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
+ """Liquidaciones module views."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from datetime import date
20
+
21
+ from flask import Blueprint, flash, redirect, render_template, request, send_file, url_for
22
+ from flask_login import login_required, current_user
23
+
24
+ from coati_payroll.i18n import _
25
+ from coati_payroll.model import Liquidacion, LiquidacionConcepto, Empleado, db
26
+ from coati_payroll.model import PlanillaEmpleado
27
+ from coati_payroll.rbac import require_read_access, require_write_access
28
+ from coati_payroll.liquidacion_engine import ejecutar_liquidacion, recalcular_liquidacion
29
+ from coati_payroll.vistas.planilla.helpers import check_openpyxl_available
30
+ from coati_payroll.vistas.planilla.services import ExportService
31
+
32
+
33
+ liquidacion_bp = Blueprint("liquidacion", __name__, url_prefix="/liquidaciones")
34
+
35
+ # Constants
36
+ ROUTE_LIQUIDACION_VER = "liquidacion.ver"
37
+
38
+
39
+ @liquidacion_bp.route("/")
40
+ @login_required
41
+ @require_read_access()
42
+ def index():
43
+ """List liquidaciones."""
44
+ liquidaciones = db.session.execute(db.select(Liquidacion).order_by(Liquidacion.creado.desc())).scalars().all()
45
+ return render_template("modules/liquidacion/index.html", liquidaciones=liquidaciones)
46
+
47
+
48
+ @liquidacion_bp.route("/nueva", methods=["GET", "POST"])
49
+ @login_required
50
+ @require_write_access()
51
+ def nueva():
52
+ """Create and calculate a new liquidacion."""
53
+ empleados = (
54
+ db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
55
+ .scalars()
56
+ .all()
57
+ )
58
+ conceptos = (
59
+ db.session.execute(db.select(LiquidacionConcepto).filter_by(activo=True).order_by(LiquidacionConcepto.nombre))
60
+ .scalars()
61
+ .all()
62
+ )
63
+
64
+ if request.method == "POST":
65
+ empleado_id = request.form.get("empleado_id")
66
+ concepto_id = request.form.get("concepto_id") or None
67
+ fecha_calculo_str = request.form.get("fecha_calculo")
68
+
69
+ try:
70
+ fecha_calculo = date.fromisoformat(fecha_calculo_str) if fecha_calculo_str else date.today()
71
+ except ValueError:
72
+ flash(_("Formato de fecha inválido."), "error")
73
+ return redirect(url_for("liquidacion.nueva"))
74
+
75
+ liquidacion, errors, warnings = ejecutar_liquidacion(
76
+ empleado_id=empleado_id,
77
+ concepto_id=concepto_id,
78
+ fecha_calculo=fecha_calculo,
79
+ usuario=getattr(current_user, "usuario", None),
80
+ )
81
+
82
+ for e in errors:
83
+ flash(e, "error")
84
+ for w in warnings:
85
+ flash(w, "warning")
86
+
87
+ if liquidacion:
88
+ flash(_("Liquidación calculada."), "success")
89
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
90
+
91
+ return redirect(url_for("liquidacion.nueva"))
92
+
93
+ return render_template(
94
+ "modules/liquidacion/nueva.html", empleados=empleados, conceptos=conceptos, fecha_calculo=date.today()
95
+ )
96
+
97
+
98
+ @liquidacion_bp.route("/<liquidacion_id>")
99
+ @login_required
100
+ @require_read_access()
101
+ def ver(liquidacion_id: str):
102
+ """View liquidacion detail."""
103
+ liquidacion = db.get_or_404(Liquidacion, liquidacion_id)
104
+ return render_template("modules/liquidacion/ver.html", liquidacion=liquidacion)
105
+
106
+
107
+ @liquidacion_bp.route("/<liquidacion_id>/recalcular", methods=["POST"])
108
+ @login_required
109
+ @require_write_access()
110
+ def recalcular(liquidacion_id: str):
111
+ liquidacion = db.get_or_404(Liquidacion, liquidacion_id)
112
+ nueva, errors, warnings = recalcular_liquidacion(
113
+ liquidacion_id=liquidacion.id,
114
+ fecha_calculo=liquidacion.fecha_calculo,
115
+ usuario=getattr(current_user, "usuario", None),
116
+ )
117
+
118
+ for e in errors:
119
+ flash(e, "error")
120
+ for w in warnings:
121
+ flash(w, "warning")
122
+
123
+ if nueva:
124
+ flash(_("Liquidación recalculada."), "success")
125
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
126
+
127
+
128
+ @liquidacion_bp.route("/<liquidacion_id>/aplicar", methods=["POST"])
129
+ @login_required
130
+ @require_write_access()
131
+ def aplicar(liquidacion_id: str):
132
+ liquidacion = db.get_or_404(Liquidacion, liquidacion_id)
133
+
134
+ if liquidacion.estado != "borrador":
135
+ flash(_("Solo se pueden aplicar liquidaciones en borrador."), "error")
136
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
137
+
138
+ empleado = db.session.get(Empleado, liquidacion.empleado_id)
139
+ if not empleado:
140
+ flash(_("Empleado no encontrado."), "error")
141
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
142
+
143
+ # Mark employee inactive
144
+ empleado.activo = False
145
+ empleado.fecha_baja = liquidacion.fecha_calculo
146
+
147
+ # Deactivate all active planilla associations
148
+ asociaciones = (
149
+ db.session.execute(
150
+ db.select(PlanillaEmpleado).where(
151
+ PlanillaEmpleado.empleado_id == empleado.id,
152
+ PlanillaEmpleado.activo.is_(True),
153
+ )
154
+ )
155
+ .scalars()
156
+ .all()
157
+ )
158
+
159
+ for pe in asociaciones:
160
+ pe.activo = False
161
+ pe.fecha_fin = liquidacion.fecha_calculo
162
+
163
+ liquidacion.estado = "aplicada"
164
+ db.session.commit()
165
+ flash(_("Liquidación aplicada. Empleado marcado como inactivo y desvinculado de planillas."), "success")
166
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
167
+
168
+
169
+ @liquidacion_bp.route("/<liquidacion_id>/pagar", methods=["POST"])
170
+ @login_required
171
+ @require_write_access()
172
+ def pagar(liquidacion_id: str):
173
+ liquidacion = db.get_or_404(Liquidacion, liquidacion_id)
174
+
175
+ if liquidacion.estado != "aplicada":
176
+ flash(_("Solo se pueden pagar liquidaciones aplicadas."), "error")
177
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
178
+
179
+ liquidacion.estado = "pagada"
180
+ db.session.commit()
181
+ flash(_("Liquidación marcada como pagada."), "success")
182
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion.id))
183
+
184
+
185
+ @liquidacion_bp.route("/<liquidacion_id>/exportar-excel")
186
+ @login_required
187
+ @require_read_access()
188
+ def exportar_excel(liquidacion_id: str):
189
+ if not check_openpyxl_available():
190
+ flash(_("Excel export no disponible. Instale openpyxl."), "warning")
191
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion_id))
192
+
193
+ liquidacion = db.get_or_404(Liquidacion, liquidacion_id)
194
+
195
+ try:
196
+ output, filename = ExportService.exportar_liquidacion_excel(liquidacion)
197
+ return send_file(
198
+ output,
199
+ mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
200
+ as_attachment=True,
201
+ download_name=filename,
202
+ )
203
+ except Exception as e:
204
+ flash(_("Error al exportar liquidación: {}").format(str(e)), "error")
205
+ return redirect(url_for(ROUTE_LIQUIDACION_VER, liquidacion_id=liquidacion_id))