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,580 @@
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
+ """Payroll concepts CRUD routes: Percepciones, Deducciones, Prestaciones.
15
+
16
+ This module provides a unified, reusable backend for managing payroll concepts
17
+ (perceptions, deductions, benefits) with integrated calculation rule support.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from decimal import Decimal
23
+
24
+ from flask import Blueprint, flash, redirect, render_template, request, url_for
25
+ from flask_login import current_user
26
+
27
+ from coati_payroll.audit_helpers import (
28
+ aprobar_concepto,
29
+ crear_log_auditoria,
30
+ detectar_cambios,
31
+ marcar_como_borrador_si_editado,
32
+ puede_aprobar_concepto,
33
+ rechazar_concepto,
34
+ )
35
+ from coati_payroll.enums import EstadoAprobacion
36
+ from coati_payroll.forms import (
37
+ DeduccionForm,
38
+ PercepcionForm,
39
+ PrestacionForm,
40
+ )
41
+ from coati_payroll.i18n import _
42
+ from coati_payroll.model import Deduccion, Percepcion, Prestacion, db
43
+ from coati_payroll.rbac import require_read_access, require_write_access
44
+ from coati_payroll.vistas.constants import PER_PAGE
45
+
46
+ # Create blueprints for each concept type
47
+ percepcion_bp = Blueprint("percepcion", __name__, url_prefix="/percepciones")
48
+ deduccion_bp = Blueprint("deduccion", __name__, url_prefix="/deducciones")
49
+ prestacion_bp = Blueprint("prestacion", __name__, url_prefix="/prestaciones")
50
+
51
+ # Constants
52
+ ERROR_CONCEPT_NOT_FOUND = "%(type)s no encontrada."
53
+
54
+
55
+ # ============================================================================
56
+ # SHARED UTILITIES
57
+ # ============================================================================
58
+
59
+
60
+ def get_concept_config(concept_type: str) -> dict:
61
+ """Get configuration for a specific concept type.
62
+
63
+ Args:
64
+ concept_type: One of 'percepcion', 'deduccion', 'prestacion'
65
+
66
+ Returns:
67
+ Dictionary with model, form, labels, and template paths
68
+ """
69
+ match concept_type:
70
+ case "percepcion":
71
+ return {
72
+ "model": Percepcion,
73
+ "form": PercepcionForm,
74
+ "singular": _("Percepción"),
75
+ "plural": _("Percepciones"),
76
+ "icon": "bi-plus-circle",
77
+ "template_dir": "modules/percepcion",
78
+ "blueprint": "percepcion",
79
+ "route_prefix": "percepcion_",
80
+ }
81
+ case "deduccion":
82
+ return {
83
+ "model": Deduccion,
84
+ "form": DeduccionForm,
85
+ "singular": _("Deducción"),
86
+ "plural": _("Deducciones"),
87
+ "icon": "bi-dash-circle",
88
+ "template_dir": "modules/deduccion",
89
+ "blueprint": "deduccion",
90
+ "route_prefix": "deduccion_",
91
+ }
92
+ case "prestacion":
93
+ return {
94
+ "model": Prestacion,
95
+ "form": PrestacionForm,
96
+ "singular": _("Prestación"),
97
+ "plural": _("Prestaciones"),
98
+ "icon": "bi-gift",
99
+ "template_dir": "modules/prestacion",
100
+ "blueprint": "prestacion",
101
+ "route_prefix": "prestacion_",
102
+ }
103
+ case _:
104
+ raise ValueError(f"Unknown concept type: {concept_type}")
105
+
106
+
107
+ def list_concepts(concept_type: str):
108
+ """Generic list view for payroll concepts."""
109
+ config = get_concept_config(concept_type)
110
+ Model = config["model"]
111
+
112
+ page = request.args.get("page", 1, type=int)
113
+ pagination = db.paginate(
114
+ db.select(Model).order_by(Model.codigo),
115
+ page=page,
116
+ per_page=PER_PAGE,
117
+ error_out=False,
118
+ )
119
+ return render_template(
120
+ f"{config['template_dir']}/index.html",
121
+ items=pagination.items,
122
+ pagination=pagination,
123
+ config=config,
124
+ )
125
+
126
+
127
+ def create_concept(concept_type: str):
128
+ """Generic create view for payroll concepts."""
129
+ config = get_concept_config(concept_type)
130
+ Model = config["model"]
131
+ Form = config["form"]
132
+
133
+ form = Form()
134
+
135
+ if form.validate_on_submit():
136
+ concept = Model()
137
+ populate_concept_from_form(concept, form)
138
+ concept.creado_por = current_user.usuario
139
+
140
+ # Set initial status as draft
141
+ concept.estado_aprobacion = EstadoAprobacion.BORRADOR
142
+
143
+ db.session.add(concept)
144
+ db.session.flush() # Get the ID before creating audit log
145
+
146
+ # Create audit log for creation
147
+ crear_log_auditoria(
148
+ concepto=concept,
149
+ accion="created",
150
+ usuario=current_user.usuario,
151
+ descripcion=f"Creó {concept_type} '{concept.nombre}' (código: {concept.codigo})",
152
+ estado_nuevo=EstadoAprobacion.BORRADOR,
153
+ )
154
+
155
+ db.session.commit()
156
+ flash(_("%(type)s creada exitosamente en estado borrador.", type=config["singular"]), "success")
157
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
158
+
159
+ return render_template(
160
+ f"{config['template_dir']}/form.html",
161
+ form=form,
162
+ title=_("Nueva %(type)s", type=config["singular"]),
163
+ config=config,
164
+ )
165
+
166
+
167
+ def edit_concept(concept_type: str, concept_id: str):
168
+ """Generic edit view for payroll concepts."""
169
+ config = get_concept_config(concept_type)
170
+ Model = config["model"]
171
+ Form = config["form"]
172
+
173
+ concept = db.session.get(Model, concept_id)
174
+ if not concept:
175
+ flash(_(ERROR_CONCEPT_NOT_FOUND, type=config["singular"]), "error")
176
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
177
+
178
+ # Store original values for change detection
179
+ original_data = {
180
+ "nombre": concept.nombre,
181
+ "descripcion": concept.descripcion,
182
+ "codigo": concept.codigo,
183
+ "formula_tipo": concept.formula_tipo,
184
+ "monto_default": concept.monto_default,
185
+ "porcentaje": concept.porcentaje,
186
+ "base_calculo": concept.base_calculo,
187
+ "activo": concept.activo,
188
+ }
189
+
190
+ form = Form(obj=concept)
191
+
192
+ if form.validate_on_submit():
193
+ populate_concept_from_form(concept, form)
194
+ concept.modificado_por = current_user.usuario
195
+
196
+ # Detect changes
197
+ new_data = {
198
+ "nombre": concept.nombre,
199
+ "descripcion": concept.descripcion,
200
+ "codigo": concept.codigo,
201
+ "formula_tipo": concept.formula_tipo,
202
+ "monto_default": concept.monto_default,
203
+ "porcentaje": concept.porcentaje,
204
+ "base_calculo": concept.base_calculo,
205
+ "activo": concept.activo,
206
+ }
207
+ cambios = detectar_cambios(original_data, new_data)
208
+
209
+ # Mark as draft if edited (unless created by plugin)
210
+ if cambios:
211
+ marcar_como_borrador_si_editado(concept, current_user.usuario, cambios)
212
+
213
+ db.session.commit()
214
+
215
+ if concept.estado_aprobacion == EstadoAprobacion.BORRADOR and not concept.creado_por_plugin:
216
+ flash(
217
+ _("%(type)s actualizada. Estado cambiado a borrador - requiere aprobación.", type=config["singular"]),
218
+ "warning",
219
+ )
220
+ else:
221
+ flash(_("%(type)s actualizada exitosamente.", type=config["singular"]), "success")
222
+
223
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
224
+
225
+ return render_template(
226
+ f"{config['template_dir']}/form.html",
227
+ form=form,
228
+ title=_("Editar %(type)s", type=config["singular"]),
229
+ concept=concept,
230
+ config=config,
231
+ )
232
+
233
+
234
+ def delete_concept(concept_type: str, concept_id: str):
235
+ """Generic delete view for payroll concepts."""
236
+ config = get_concept_config(concept_type)
237
+ Model = config["model"]
238
+
239
+ concept = db.session.get(Model, concept_id)
240
+ if not concept:
241
+ flash(_(ERROR_CONCEPT_NOT_FOUND, type=config["singular"]), "error")
242
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
243
+
244
+ # Check if concept is in use
245
+ if hasattr(concept, "planillas") and concept.planillas:
246
+ flash(
247
+ _(
248
+ "No se puede eliminar: %(type)s está asociada a una o más planillas.",
249
+ type=config["singular"],
250
+ ),
251
+ "error",
252
+ )
253
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
254
+
255
+ db.session.delete(concept)
256
+ db.session.commit()
257
+ flash(_("%(type)s eliminada exitosamente.", type=config["singular"]), "success")
258
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
259
+
260
+
261
+ def populate_concept_from_form(concept, form):
262
+ """Populate a concept model from a form.
263
+
264
+ This is a shared function that handles the common fields
265
+ across Percepcion, Deduccion, and Prestacion.
266
+ """
267
+ concept.codigo = form.codigo.data
268
+ concept.nombre = form.nombre.data
269
+ concept.descripcion = form.descripcion.data
270
+ concept.formula_tipo = form.formula_tipo.data
271
+ concept.activo = form.activo.data
272
+
273
+ # Handle monto_default
274
+ if form.monto_default.data:
275
+ concept.monto_default = Decimal(str(form.monto_default.data))
276
+ else:
277
+ concept.monto_default = None
278
+
279
+ # Handle porcentaje
280
+ if form.porcentaje.data:
281
+ concept.porcentaje = Decimal(str(form.porcentaje.data))
282
+ else:
283
+ concept.porcentaje = None
284
+
285
+ # Common optional fields
286
+ if hasattr(form, "base_calculo") and form.base_calculo.data:
287
+ concept.base_calculo = form.base_calculo.data
288
+
289
+ if hasattr(form, "unidad_calculo") and form.unidad_calculo.data:
290
+ concept.unidad_calculo = form.unidad_calculo.data
291
+
292
+ if hasattr(form, "recurrente"):
293
+ concept.recurrente = form.recurrente.data
294
+
295
+ if hasattr(form, "contabilizable"):
296
+ concept.contabilizable = form.contabilizable.data
297
+
298
+ if hasattr(form, "codigo_cuenta_debe") and form.codigo_cuenta_debe.data:
299
+ concept.codigo_cuenta_debe = form.codigo_cuenta_debe.data
300
+
301
+ if hasattr(form, "descripcion_cuenta_debe") and form.descripcion_cuenta_debe.data:
302
+ concept.descripcion_cuenta_debe = form.descripcion_cuenta_debe.data
303
+
304
+ if hasattr(form, "codigo_cuenta_haber") and form.codigo_cuenta_haber.data:
305
+ concept.codigo_cuenta_haber = form.codigo_cuenta_haber.data
306
+
307
+ if hasattr(form, "descripcion_cuenta_haber") and form.descripcion_cuenta_haber.data:
308
+ concept.descripcion_cuenta_haber = form.descripcion_cuenta_haber.data
309
+
310
+ if hasattr(form, "editable_en_nomina"):
311
+ concept.editable_en_nomina = form.editable_en_nomina.data
312
+
313
+ # Vigencia fields
314
+ if hasattr(form, "vigente_desde"):
315
+ concept.vigente_desde = form.vigente_desde.data
316
+
317
+ if hasattr(form, "valido_hasta"):
318
+ concept.valido_hasta = form.valido_hasta.data
319
+
320
+ # Type-specific fields
321
+ if hasattr(form, "gravable"): # Percepcion
322
+ concept.gravable = form.gravable.data
323
+
324
+ if hasattr(form, "tipo") and form.tipo.data: # Deduccion, Prestacion
325
+ concept.tipo = form.tipo.data
326
+
327
+ if hasattr(form, "es_impuesto"): # Deduccion
328
+ concept.es_impuesto = form.es_impuesto.data
329
+
330
+ if hasattr(form, "antes_impuesto"): # Deduccion
331
+ concept.antes_impuesto = form.antes_impuesto.data
332
+
333
+ # Prestacion-specific fields
334
+ if hasattr(form, "tope_aplicacion"):
335
+ if form.tope_aplicacion.data:
336
+ concept.tope_aplicacion = Decimal(str(form.tope_aplicacion.data))
337
+ else:
338
+ concept.tope_aplicacion = None
339
+
340
+
341
+ # ============================================================================
342
+ # PERCEPCION ROUTES
343
+ # ============================================================================
344
+
345
+
346
+ @percepcion_bp.route("/")
347
+ @require_read_access()
348
+ def percepcion_index():
349
+ """List all perceptions."""
350
+ return list_concepts("percepcion")
351
+
352
+
353
+ @percepcion_bp.route("/new", methods=["GET", "POST"])
354
+ @require_write_access()
355
+ def percepcion_new():
356
+ """Create a new perception. Admin and HR can create perceptions."""
357
+ return create_concept("percepcion")
358
+
359
+
360
+ @percepcion_bp.route("/edit/<string:concept_id>", methods=["GET", "POST"])
361
+ @require_write_access()
362
+ def percepcion_edit(concept_id: str):
363
+ """Edit an existing perception. Admin and HR can edit perceptions."""
364
+ return edit_concept("percepcion", concept_id)
365
+
366
+
367
+ @percepcion_bp.route("/delete/<string:concept_id>", methods=["POST"])
368
+ @require_write_access()
369
+ def percepcion_delete(concept_id: str):
370
+ """Delete a perception. Admin and HR can delete perceptions."""
371
+ return delete_concept("percepcion", concept_id)
372
+
373
+
374
+ # ============================================================================
375
+ # DEDUCCION ROUTES
376
+ # ============================================================================
377
+
378
+
379
+ @deduccion_bp.route("/")
380
+ @require_read_access()
381
+ def deduccion_index():
382
+ """List all deductions."""
383
+ return list_concepts("deduccion")
384
+
385
+
386
+ @deduccion_bp.route("/new", methods=["GET", "POST"])
387
+ @require_write_access()
388
+ def deduccion_new():
389
+ """Create a new deduction. Admin and HR can create deductions."""
390
+ return create_concept("deduccion")
391
+
392
+
393
+ @deduccion_bp.route("/edit/<string:concept_id>", methods=["GET", "POST"])
394
+ @require_write_access()
395
+ def deduccion_edit(concept_id: str):
396
+ """Edit an existing deduction. Admin and HR can edit deductions."""
397
+ return edit_concept("deduccion", concept_id)
398
+
399
+
400
+ @deduccion_bp.route("/delete/<string:concept_id>", methods=["POST"])
401
+ @require_write_access()
402
+ def deduccion_delete(concept_id: str):
403
+ """Delete a deduction. Admin and HR can delete deductions."""
404
+ return delete_concept("deduccion", concept_id)
405
+
406
+
407
+ # ============================================================================
408
+ # PRESTACION ROUTES
409
+ # ============================================================================
410
+
411
+
412
+ @prestacion_bp.route("/")
413
+ @require_read_access()
414
+ def prestacion_index():
415
+ """List all benefits."""
416
+ return list_concepts("prestacion")
417
+
418
+
419
+ @prestacion_bp.route("/new", methods=["GET", "POST"])
420
+ @require_write_access()
421
+ def prestacion_new():
422
+ """Create a new benefit. Admin and HR can create benefits."""
423
+ return create_concept("prestacion")
424
+
425
+
426
+ @prestacion_bp.route("/edit/<string:concept_id>", methods=["GET", "POST"])
427
+ @require_write_access()
428
+ def prestacion_edit(concept_id: str):
429
+ """Edit an existing benefit. Admin and HR can edit benefits."""
430
+ return edit_concept("prestacion", concept_id)
431
+
432
+
433
+ @prestacion_bp.route("/delete/<string:concept_id>", methods=["POST"])
434
+ @require_write_access()
435
+ def prestacion_delete(concept_id: str):
436
+ """Delete a benefit. Admin and HR can delete benefits."""
437
+ return delete_concept("prestacion", concept_id)
438
+
439
+
440
+ # ============================================================================
441
+ # APPROVAL ROUTES (for all concept types)
442
+ # ============================================================================
443
+
444
+
445
+ def approve_concept_route(concept_type: str, concept_id: str):
446
+ """Generic approval route for payroll concepts."""
447
+ # Check if user can approve
448
+ if not puede_aprobar_concepto(current_user.tipo):
449
+ flash(_("No tiene permisos para aprobar conceptos de nómina."), "error")
450
+ return redirect(url_for(f"{concept_type}.{concept_type}_index"))
451
+
452
+ config = get_concept_config(concept_type)
453
+ Model = config["model"]
454
+
455
+ concept = db.session.get(Model, concept_id)
456
+ if not concept:
457
+ flash(_(ERROR_CONCEPT_NOT_FOUND, type=config["singular"]), "error")
458
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
459
+
460
+ # Approve the concept
461
+ if aprobar_concepto(concept, current_user.usuario):
462
+ db.session.commit()
463
+ flash(_("%(type)s aprobada exitosamente.", type=config["singular"]), "success")
464
+ else:
465
+ flash(_("%(type)s ya está aprobada.", type=config["singular"]), "info")
466
+
467
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
468
+
469
+
470
+ def reject_concept_route(concept_type: str, concept_id: str):
471
+ """Generic rejection route for payroll concepts."""
472
+ # Check if user can approve/reject
473
+ if not puede_aprobar_concepto(current_user.tipo):
474
+ flash(_("No tiene permisos para rechazar conceptos de nómina."), "error")
475
+ return redirect(url_for(f"{concept_type}.{concept_type}_index"))
476
+
477
+ config = get_concept_config(concept_type)
478
+ Model = config["model"]
479
+
480
+ concept = db.session.get(Model, concept_id)
481
+ if not concept:
482
+ flash(_(ERROR_CONCEPT_NOT_FOUND, type=config["singular"]), "error")
483
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
484
+
485
+ # Get rejection reason from form
486
+ razon = request.form.get("razon", "")
487
+
488
+ # Reject the concept
489
+ rechazar_concepto(concept, current_user.usuario, razon)
490
+ db.session.commit()
491
+ flash(_("%(type)s marcada como borrador.", type=config["singular"]), "warning")
492
+
493
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
494
+
495
+
496
+ def view_audit_log_route(concept_type: str, concept_id: str):
497
+ """View audit log for a specific concept."""
498
+ config = get_concept_config(concept_type)
499
+ Model = config["model"]
500
+
501
+ concept = db.session.get(Model, concept_id)
502
+ if not concept:
503
+ flash(_(ERROR_CONCEPT_NOT_FOUND, type=config["singular"]), "error")
504
+ return redirect(url_for(f"{config['blueprint']}.{concept_type}_index"))
505
+
506
+ # Get audit logs
507
+ audit_logs = sorted(concept.audit_logs, key=lambda x: x.timestamp, reverse=True)
508
+
509
+ return render_template(
510
+ "modules/payroll_concepts/audit_log.html",
511
+ concept=concept,
512
+ audit_logs=audit_logs,
513
+ config=config,
514
+ )
515
+
516
+
517
+ # Percepcion approval routes
518
+ @percepcion_bp.route("/approve/<string:concept_id>", methods=["POST"])
519
+ @require_write_access()
520
+ def percepcion_approve(concept_id: str):
521
+ """Approve a perception. Only ADMIN and HHRR can approve."""
522
+ return approve_concept_route("percepcion", concept_id)
523
+
524
+
525
+ @percepcion_bp.route("/reject/<string:concept_id>", methods=["POST"])
526
+ @require_write_access()
527
+ def percepcion_reject(concept_id: str):
528
+ """Reject a perception. Only ADMIN and HHRR can reject."""
529
+ return reject_concept_route("percepcion", concept_id)
530
+
531
+
532
+ @percepcion_bp.route("/audit/<string:concept_id>")
533
+ @require_read_access()
534
+ def percepcion_audit(concept_id: str):
535
+ """View audit log for a perception."""
536
+ return view_audit_log_route("percepcion", concept_id)
537
+
538
+
539
+ # Deduccion approval routes
540
+ @deduccion_bp.route("/approve/<string:concept_id>", methods=["POST"])
541
+ @require_write_access()
542
+ def deduccion_approve(concept_id: str):
543
+ """Approve a deduction. Only ADMIN and HHRR can approve."""
544
+ return approve_concept_route("deduccion", concept_id)
545
+
546
+
547
+ @deduccion_bp.route("/reject/<string:concept_id>", methods=["POST"])
548
+ @require_write_access()
549
+ def deduccion_reject(concept_id: str):
550
+ """Reject a deduction. Only ADMIN and HHRR can reject."""
551
+ return reject_concept_route("deduccion", concept_id)
552
+
553
+
554
+ @deduccion_bp.route("/audit/<string:concept_id>")
555
+ @require_read_access()
556
+ def deduccion_audit(concept_id: str):
557
+ """View audit log for a deduction."""
558
+ return view_audit_log_route("deduccion", concept_id)
559
+
560
+
561
+ # Prestacion approval routes
562
+ @prestacion_bp.route("/approve/<string:concept_id>", methods=["POST"])
563
+ @require_write_access()
564
+ def prestacion_approve(concept_id: str):
565
+ """Approve a benefit. Only ADMIN and HHRR can approve."""
566
+ return approve_concept_route("prestacion", concept_id)
567
+
568
+
569
+ @prestacion_bp.route("/reject/<string:concept_id>", methods=["POST"])
570
+ @require_write_access()
571
+ def prestacion_reject(concept_id: str):
572
+ """Reject a benefit. Only ADMIN and HHRR can reject."""
573
+ return reject_concept_route("prestacion", concept_id)
574
+
575
+
576
+ @prestacion_bp.route("/audit/<string:concept_id>")
577
+ @require_read_access()
578
+ def prestacion_audit(concept_id: str):
579
+ """View audit log for a benefit."""
580
+ return view_audit_log_route("prestacion", concept_id)
@@ -0,0 +1,38 @@
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
+ """Views for managing Planilla (master payroll) and its associations.
15
+
16
+ A Planilla is the central hub that connects:
17
+ - Employees (via PlanillaEmpleado)
18
+ - Perceptions (via PlanillaIngreso)
19
+ - Deductions (via PlanillaDeduccion) - with priority ordering
20
+ - Benefits/Prestaciones (via PlanillaPrestacion)
21
+ - Calculation Rules (via PlanillaReglaCalculo)
22
+ """
23
+
24
+ from flask import Blueprint
25
+
26
+ # Create the blueprint
27
+ planilla_bp = Blueprint("planilla", __name__, url_prefix="/planilla")
28
+
29
+ # Import all route modules to register them with the blueprint
30
+ # This must be done after creating the blueprint
31
+ from coati_payroll.vistas.planilla import routes # noqa: E402, F401
32
+ from coati_payroll.vistas.planilla import config_routes # noqa: E402, F401
33
+ from coati_payroll.vistas.planilla import association_routes # noqa: E402, F401
34
+ from coati_payroll.vistas.planilla import nomina_routes # noqa: E402, F401
35
+ from coati_payroll.vistas.planilla import novedad_routes # noqa: E402, F401
36
+ from coati_payroll.vistas.planilla import export_routes # noqa: E402, F401
37
+
38
+ __all__ = ["planilla_bp"]