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,904 @@
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
+ """Helper functions for audit and governance of payroll concepts."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, Optional
19
+
20
+
21
+ from coati_payroll.enums import EstadoAprobacion, TipoUsuario, NominaEstado
22
+ from coati_payroll.model import (
23
+ ConceptoAuditLog,
24
+ PlanillaAuditLog,
25
+ NominaAuditLog,
26
+ ReglaCalculoAuditLog,
27
+ Percepcion,
28
+ Deduccion,
29
+ Prestacion,
30
+ Planilla,
31
+ Nomina,
32
+ ReglaCalculo,
33
+ db,
34
+ utc_now,
35
+ )
36
+
37
+
38
+ def puede_aprobar_concepto(usuario_tipo: str) -> bool:
39
+ """Check if user can approve payroll concepts.
40
+
41
+ Only ADMIN and HHRR users can approve percepciones, deducciones, and prestaciones.
42
+
43
+ Args:
44
+ usuario_tipo: User type (admin, hhrr, audit)
45
+
46
+ Returns:
47
+ True if user can approve concepts, False otherwise
48
+ """
49
+ return usuario_tipo in [TipoUsuario.ADMIN, TipoUsuario.HHRR]
50
+
51
+
52
+ def crear_log_auditoria(
53
+ concepto: Percepcion | Deduccion | Prestacion,
54
+ accion: str,
55
+ usuario: str,
56
+ descripcion: Optional[str] = None,
57
+ cambios: Optional[Dict[str, Any]] = None,
58
+ estado_anterior: Optional[str] = None,
59
+ estado_nuevo: Optional[str] = None,
60
+ ) -> ConceptoAuditLog:
61
+ """Create an audit log entry for a payroll concept change.
62
+
63
+ Args:
64
+ concepto: The concept that was changed (Percepcion, Deduccion, or Prestacion)
65
+ accion: Action performed (created, updated, approved, rejected, etc.)
66
+ usuario: Username who performed the action
67
+ descripcion: Human-readable description of the change
68
+ cambios: Dictionary of field-level changes {field: {old: value, new: value}}
69
+ estado_anterior: Previous approval status
70
+ estado_nuevo: New approval status
71
+
72
+ Returns:
73
+ The created audit log entry
74
+ """
75
+ # Determine concept type
76
+ if isinstance(concepto, Percepcion):
77
+ tipo_concepto = "percepcion"
78
+ percepcion_id = concepto.id
79
+ deduccion_id = None
80
+ prestacion_id = None
81
+ elif isinstance(concepto, Deduccion):
82
+ tipo_concepto = "deduccion"
83
+ percepcion_id = None
84
+ deduccion_id = concepto.id
85
+ prestacion_id = None
86
+ elif isinstance(concepto, Prestacion):
87
+ tipo_concepto = "prestacion"
88
+ percepcion_id = None
89
+ deduccion_id = None
90
+ prestacion_id = concepto.id
91
+ else:
92
+ raise ValueError(f"Invalid concept type: {type(concepto)}")
93
+
94
+ # Create audit log entry
95
+ log = ConceptoAuditLog(
96
+ tipo_concepto=tipo_concepto,
97
+ percepcion_id=percepcion_id,
98
+ deduccion_id=deduccion_id,
99
+ prestacion_id=prestacion_id,
100
+ accion=accion,
101
+ usuario=usuario,
102
+ descripcion=descripcion,
103
+ cambios=cambios or {},
104
+ estado_anterior=estado_anterior,
105
+ estado_nuevo=estado_nuevo,
106
+ )
107
+
108
+ db.session.add(log)
109
+ return log
110
+
111
+
112
+ def generar_descripcion_cambios(cambios: Dict[str, Any]) -> str:
113
+ """Generate a human-readable description of changes.
114
+
115
+ Args:
116
+ cambios: Dictionary of field-level changes
117
+
118
+ Returns:
119
+ Human-readable description
120
+ """
121
+ if not cambios:
122
+ return ""
123
+
124
+ descripciones = []
125
+ for campo, valores in cambios.items():
126
+ old_val = valores.get("old", "")
127
+ new_val = valores.get("new", "")
128
+
129
+ # Format field name
130
+ campo_legible = campo.replace("_", " ").title()
131
+
132
+ if old_val == "" or old_val is None:
133
+ descripciones.append(f"{campo_legible} establecido a {new_val}")
134
+ elif new_val == "" or new_val is None:
135
+ descripciones.append(f"{campo_legible} eliminado (era {old_val})")
136
+ else:
137
+ descripciones.append(f"{campo_legible} cambió de {old_val} a {new_val}")
138
+
139
+ return "; ".join(descripciones)
140
+
141
+
142
+ def aprobar_concepto(
143
+ concepto: Percepcion | Deduccion | Prestacion,
144
+ usuario: str,
145
+ ) -> bool:
146
+ """Approve a payroll concept.
147
+
148
+ Changes status from 'borrador' to 'aprobado' and records approval information.
149
+
150
+ Args:
151
+ concepto: The concept to approve
152
+ usuario: Username who is approving
153
+
154
+ Returns:
155
+ True if approved successfully, False if already approved or invalid
156
+ """
157
+ if concepto.estado_aprobacion == EstadoAprobacion.APROBADO:
158
+ return False
159
+
160
+ estado_anterior = concepto.estado_aprobacion
161
+ concepto.estado_aprobacion = EstadoAprobacion.APROBADO
162
+ concepto.aprobado_por = usuario
163
+ concepto.aprobado_en = utc_now()
164
+
165
+ # Create audit log
166
+ tipo_concepto = type(concepto).__name__.lower()
167
+ crear_log_auditoria(
168
+ concepto=concepto,
169
+ accion="approved",
170
+ usuario=usuario,
171
+ descripcion=f"Aprobó {tipo_concepto} '{concepto.nombre}' (código: {concepto.codigo})",
172
+ estado_anterior=estado_anterior,
173
+ estado_nuevo=EstadoAprobacion.APROBADO,
174
+ )
175
+
176
+ return True
177
+
178
+
179
+ def rechazar_concepto(
180
+ concepto: Percepcion | Deduccion | Prestacion,
181
+ usuario: str,
182
+ razon: Optional[str] = None,
183
+ ) -> bool:
184
+ """Reject a payroll concept (keep as draft).
185
+
186
+ Args:
187
+ concepto: The concept to reject
188
+ usuario: Username who is rejecting
189
+ razon: Reason for rejection
190
+
191
+ Returns:
192
+ True if rejected successfully
193
+ """
194
+ estado_anterior = concepto.estado_aprobacion
195
+ concepto.estado_aprobacion = EstadoAprobacion.BORRADOR
196
+ concepto.aprobado_por = None
197
+ concepto.aprobado_en = None
198
+
199
+ # Create audit log
200
+ tipo_concepto = type(concepto).__name__.lower()
201
+ descripcion = f"Rechazó {tipo_concepto} '{concepto.nombre}' (código: {concepto.codigo})"
202
+ if razon:
203
+ descripcion += f" - Razón: {razon}"
204
+
205
+ crear_log_auditoria(
206
+ concepto=concepto,
207
+ accion="rejected",
208
+ usuario=usuario,
209
+ descripcion=descripcion,
210
+ estado_anterior=estado_anterior,
211
+ estado_nuevo=EstadoAprobacion.BORRADOR,
212
+ )
213
+
214
+ return True
215
+
216
+
217
+ def marcar_como_borrador_si_editado(
218
+ concepto: Percepcion | Deduccion | Prestacion,
219
+ usuario: str,
220
+ cambios: Dict[str, Any],
221
+ ) -> None:
222
+ """Mark concept as draft if it was edited while approved.
223
+
224
+ When an approved concept is edited, it must return to draft status
225
+ unless it was created by a plugin.
226
+
227
+ Args:
228
+ concepto: The concept that was edited
229
+ usuario: Username who edited
230
+ cambios: Dictionary of changes made
231
+ """
232
+ # Don't change status if created by plugin
233
+ if concepto.creado_por_plugin:
234
+ return
235
+
236
+ # If currently approved, mark as draft
237
+ if concepto.estado_aprobacion == EstadoAprobacion.APROBADO:
238
+ estado_anterior = concepto.estado_aprobacion
239
+ concepto.estado_aprobacion = EstadoAprobacion.BORRADOR
240
+ concepto.aprobado_por = None
241
+ concepto.aprobado_en = None
242
+
243
+ # Create audit log
244
+ tipo_concepto = type(concepto).__name__.lower()
245
+ descripcion_cambios = generar_descripcion_cambios(cambios)
246
+
247
+ crear_log_auditoria(
248
+ concepto=concepto,
249
+ accion="updated",
250
+ usuario=usuario,
251
+ descripcion=f"Editó {tipo_concepto} '{concepto.nombre}' - {descripcion_cambios}."
252
+ + " Estado cambiado a borrador.",
253
+ cambios=cambios,
254
+ estado_anterior=estado_anterior,
255
+ estado_nuevo=EstadoAprobacion.BORRADOR,
256
+ )
257
+
258
+
259
+ def detectar_cambios(concepto_original: Dict[str, Any], concepto_nuevo: Dict[str, Any]) -> Dict[str, Any]:
260
+ """Detect changes between original and new concept data.
261
+
262
+ Args:
263
+ concepto_original: Original concept data
264
+ concepto_nuevo: New concept data
265
+
266
+ Returns:
267
+ Dictionary of changes {field: {old: value, new: value}}
268
+ """
269
+ cambios = {}
270
+
271
+ # Fields to track
272
+ campos_importantes = [
273
+ "nombre",
274
+ "descripcion",
275
+ "codigo",
276
+ "formula_tipo",
277
+ "monto_default",
278
+ "porcentaje",
279
+ "base_calculo",
280
+ "gravable",
281
+ "recurrente",
282
+ "activo",
283
+ "codigo_cuenta_debe",
284
+ "codigo_cuenta_haber",
285
+ "tipo",
286
+ "es_impuesto",
287
+ "antes_impuesto",
288
+ "tipo_acumulacion",
289
+ "tope_aplicacion",
290
+ ]
291
+
292
+ for campo in campos_importantes:
293
+ if campo in concepto_original and campo in concepto_nuevo:
294
+ old_val = concepto_original[campo]
295
+ new_val = concepto_nuevo[campo]
296
+
297
+ # Compare values (handle None and empty strings as equivalent)
298
+ if (old_val or "") != (new_val or ""):
299
+ cambios[campo] = {"old": old_val, "new": new_val}
300
+
301
+ return cambios
302
+
303
+
304
+ def obtener_conceptos_en_borrador(planilla_id: str) -> Dict[str, list]:
305
+ """Get all draft concepts associated with a planilla.
306
+
307
+ Args:
308
+ planilla_id: ID of the planilla
309
+
310
+ Returns:
311
+ Dictionary with lists of draft percepciones, deducciones, and prestaciones
312
+ """
313
+ from coati_payroll.model import PlanillaIngreso, PlanillaDeduccion, PlanillaPrestacion
314
+
315
+ # Get draft percepciones
316
+ percepciones_borrador = (
317
+ db.session.query(Percepcion)
318
+ .join(PlanillaIngreso)
319
+ .filter(
320
+ PlanillaIngreso.planilla_id == planilla_id,
321
+ Percepcion.estado_aprobacion == EstadoAprobacion.BORRADOR,
322
+ Percepcion.activo == True, # noqa: E712
323
+ )
324
+ .all()
325
+ )
326
+
327
+ # Get draft deducciones
328
+ deducciones_borrador = (
329
+ db.session.query(Deduccion)
330
+ .join(PlanillaDeduccion)
331
+ .filter(
332
+ PlanillaDeduccion.planilla_id == planilla_id,
333
+ Deduccion.estado_aprobacion == EstadoAprobacion.BORRADOR,
334
+ Deduccion.activo == True, # noqa: E712
335
+ )
336
+ .all()
337
+ )
338
+
339
+ # Get draft prestaciones
340
+ prestaciones_borrador = (
341
+ db.session.query(Prestacion)
342
+ .join(PlanillaPrestacion)
343
+ .filter(
344
+ PlanillaPrestacion.planilla_id == planilla_id,
345
+ Prestacion.estado_aprobacion == EstadoAprobacion.BORRADOR,
346
+ Prestacion.activo == True, # noqa: E712
347
+ )
348
+ .all()
349
+ )
350
+
351
+ return {
352
+ "percepciones": percepciones_borrador,
353
+ "deducciones": deducciones_borrador,
354
+ "prestaciones": prestaciones_borrador,
355
+ }
356
+
357
+
358
+ def tiene_conceptos_en_borrador(planilla_id: str) -> bool:
359
+ """Check if a planilla has any draft concepts.
360
+
361
+ Args:
362
+ planilla_id: ID of the planilla
363
+
364
+ Returns:
365
+ True if there are any draft concepts, False otherwise
366
+ """
367
+ conceptos = obtener_conceptos_en_borrador(planilla_id)
368
+ return bool(conceptos["percepciones"] or conceptos["deducciones"] or conceptos["prestaciones"])
369
+
370
+
371
+ # ============================================================================
372
+ # PLANILLA AUDIT FUNCTIONS
373
+ # ============================================================================
374
+
375
+
376
+ def crear_log_auditoria_planilla(
377
+ planilla: Planilla,
378
+ accion: str,
379
+ usuario: str,
380
+ descripcion: Optional[str] = None,
381
+ cambios: Optional[Dict[str, Any]] = None,
382
+ estado_anterior: Optional[str] = None,
383
+ estado_nuevo: Optional[str] = None,
384
+ ) -> PlanillaAuditLog:
385
+ """Create an audit log entry for a planilla change.
386
+
387
+ Args:
388
+ planilla: The planilla that was changed
389
+ accion: Action performed (created, updated, approved, rejected, etc.)
390
+ usuario: Username who performed the action
391
+ descripcion: Human-readable description of the change
392
+ cambios: Dictionary of field-level changes
393
+ estado_anterior: Previous approval status
394
+ estado_nuevo: New approval status
395
+
396
+ Returns:
397
+ The created audit log entry
398
+ """
399
+ log = PlanillaAuditLog(
400
+ planilla_id=planilla.id,
401
+ accion=accion,
402
+ usuario=usuario,
403
+ descripcion=descripcion,
404
+ cambios=cambios or {},
405
+ estado_anterior=estado_anterior,
406
+ estado_nuevo=estado_nuevo,
407
+ )
408
+
409
+ db.session.add(log)
410
+ return log
411
+
412
+
413
+ def aprobar_planilla(planilla: Planilla, usuario: str) -> bool:
414
+ """Approve a planilla.
415
+
416
+ Changes status from 'borrador' to 'aprobado' and records approval information.
417
+
418
+ Args:
419
+ planilla: The planilla to approve
420
+ usuario: Username who is approving
421
+
422
+ Returns:
423
+ True if approved successfully, False if already approved or invalid
424
+ """
425
+ if planilla.estado_aprobacion == EstadoAprobacion.APROBADO:
426
+ return False
427
+
428
+ estado_anterior = planilla.estado_aprobacion
429
+ planilla.estado_aprobacion = EstadoAprobacion.APROBADO
430
+ planilla.aprobado_por = usuario
431
+ planilla.aprobado_en = utc_now()
432
+
433
+ # Create audit log
434
+ crear_log_auditoria_planilla(
435
+ planilla=planilla,
436
+ accion="approved",
437
+ usuario=usuario,
438
+ descripcion=f"Aprobó planilla '{planilla.nombre}'",
439
+ estado_anterior=estado_anterior,
440
+ estado_nuevo=EstadoAprobacion.APROBADO,
441
+ )
442
+
443
+ return True
444
+
445
+
446
+ def rechazar_planilla(planilla: Planilla, usuario: str, razon: Optional[str] = None) -> bool:
447
+ """Reject a planilla (keep as draft).
448
+
449
+ Args:
450
+ planilla: The planilla to reject
451
+ usuario: Username who is rejecting
452
+ razon: Reason for rejection
453
+
454
+ Returns:
455
+ True if rejected successfully
456
+ """
457
+ estado_anterior = planilla.estado_aprobacion
458
+ planilla.estado_aprobacion = EstadoAprobacion.BORRADOR
459
+ planilla.aprobado_por = None
460
+ planilla.aprobado_en = None
461
+
462
+ # Create audit log
463
+ descripcion = f"Rechazó planilla '{planilla.nombre}'"
464
+ if razon:
465
+ descripcion += f" - Razón: {razon}"
466
+
467
+ crear_log_auditoria_planilla(
468
+ planilla=planilla,
469
+ accion="rejected",
470
+ usuario=usuario,
471
+ descripcion=descripcion,
472
+ estado_anterior=estado_anterior,
473
+ estado_nuevo=EstadoAprobacion.BORRADOR,
474
+ )
475
+
476
+ return True
477
+
478
+
479
+ def marcar_planilla_como_borrador_si_editada(
480
+ planilla: Planilla,
481
+ usuario: str,
482
+ cambios: Dict[str, Any],
483
+ ) -> None:
484
+ """Mark planilla as draft if it was edited while approved.
485
+
486
+ When an approved planilla is edited, it must return to draft status
487
+ unless it was created by a plugin.
488
+
489
+ Args:
490
+ planilla: The planilla that was edited
491
+ usuario: Username who edited
492
+ cambios: Dictionary of changes made
493
+ """
494
+ # Don't change status if created by plugin
495
+ if planilla.creado_por_plugin:
496
+ return
497
+
498
+ # If currently approved, mark as draft
499
+ if planilla.estado_aprobacion == EstadoAprobacion.APROBADO:
500
+ estado_anterior = planilla.estado_aprobacion
501
+ planilla.estado_aprobacion = EstadoAprobacion.BORRADOR
502
+ planilla.aprobado_por = None
503
+ planilla.aprobado_en = None
504
+
505
+ # Create audit log
506
+ descripcion_cambios = generar_descripcion_cambios(cambios)
507
+
508
+ crear_log_auditoria_planilla(
509
+ planilla=planilla,
510
+ accion="updated",
511
+ usuario=usuario,
512
+ descripcion=f"Editó planilla '{planilla.nombre}' - {descripcion_cambios}. Estado cambiado a borrador.",
513
+ cambios=cambios,
514
+ estado_anterior=estado_anterior,
515
+ estado_nuevo=EstadoAprobacion.BORRADOR,
516
+ )
517
+
518
+
519
+ # ============================================================================
520
+ # NOMINA AUDIT FUNCTIONS
521
+ # ============================================================================
522
+
523
+
524
+ def crear_log_auditoria_nomina(
525
+ nomina: Nomina,
526
+ accion: str,
527
+ usuario: str,
528
+ descripcion: Optional[str] = None,
529
+ cambios: Optional[Dict[str, Any]] = None,
530
+ estado_anterior: Optional[str] = None,
531
+ estado_nuevo: Optional[str] = None,
532
+ ) -> NominaAuditLog:
533
+ """Create an audit log entry for a nomina state change.
534
+
535
+ Args:
536
+ nomina: The nomina that changed state
537
+ accion: Action performed (generated, approved, applied, cancelled, etc.)
538
+ usuario: Username who performed the action
539
+ descripcion: Human-readable description of the change
540
+ cambios: Dictionary of field-level changes
541
+ estado_anterior: Previous state
542
+ estado_nuevo: New state
543
+
544
+ Returns:
545
+ The created audit log entry
546
+ """
547
+ log = NominaAuditLog(
548
+ nomina_id=nomina.id,
549
+ accion=accion,
550
+ usuario=usuario,
551
+ descripcion=descripcion,
552
+ cambios=cambios or {},
553
+ estado_anterior=estado_anterior,
554
+ estado_nuevo=estado_nuevo,
555
+ )
556
+
557
+ db.session.add(log)
558
+ return log
559
+
560
+
561
+ def aprobar_nomina(nomina: Nomina, usuario: str) -> bool:
562
+ """Approve a nomina.
563
+
564
+ Changes state from 'generado' to 'aprobado' and records approval information.
565
+
566
+ Args:
567
+ nomina: The nomina to approve
568
+ usuario: Username who is approving
569
+
570
+ Returns:
571
+ True if approved successfully, False if already approved or invalid state
572
+ """
573
+ if nomina.estado != NominaEstado.GENERADO:
574
+ return False
575
+
576
+ estado_anterior = nomina.estado
577
+ nomina.estado = NominaEstado.APROBADO
578
+ nomina.aprobado_por = usuario
579
+ nomina.aprobado_en = utc_now()
580
+
581
+ # Create audit log
582
+ crear_log_auditoria_nomina(
583
+ nomina=nomina,
584
+ accion="approved",
585
+ usuario=usuario,
586
+ descripcion=f"Aprobó nómina del período {nomina.periodo_inicio} al {nomina.periodo_fin}",
587
+ estado_anterior=estado_anterior,
588
+ estado_nuevo=NominaEstado.APROBADO,
589
+ )
590
+
591
+ return True
592
+
593
+
594
+ def aplicar_nomina(nomina: Nomina, usuario: str) -> bool:
595
+ """Apply a nomina (mark as paid/executed).
596
+
597
+ Changes state from 'aprobado' to 'aplicado' and records application information.
598
+
599
+ Args:
600
+ nomina: The nomina to apply
601
+ usuario: Username who is applying
602
+
603
+ Returns:
604
+ True if applied successfully, False if not in approved state
605
+ """
606
+ if nomina.estado != NominaEstado.APROBADO:
607
+ return False
608
+
609
+ estado_anterior = nomina.estado
610
+ nomina.estado = NominaEstado.APLICADO
611
+ nomina.aplicado_por = usuario
612
+ nomina.aplicado_en = utc_now()
613
+
614
+ # Create audit log
615
+ crear_log_auditoria_nomina(
616
+ nomina=nomina,
617
+ accion="applied",
618
+ usuario=usuario,
619
+ descripcion=f"Aplicó nómina del período {nomina.periodo_inicio} al {nomina.periodo_fin}",
620
+ estado_anterior=estado_anterior,
621
+ estado_nuevo=NominaEstado.APLICADO,
622
+ )
623
+
624
+ return True
625
+
626
+
627
+ def anular_nomina(nomina: Nomina, usuario: str, razon: str) -> bool:
628
+ """Cancel/void a nomina.
629
+
630
+ Changes state to 'anulado' and records cancellation information.
631
+
632
+ Args:
633
+ nomina: The nomina to cancel
634
+ usuario: Username who is cancelling
635
+ razon: Reason for cancellation
636
+
637
+ Returns:
638
+ True if cancelled successfully, False if already cancelled
639
+ """
640
+ if nomina.estado == NominaEstado.ANULADO:
641
+ return False
642
+
643
+ estado_anterior = nomina.estado
644
+ nomina.estado = NominaEstado.ANULADO
645
+ nomina.anulado_por = usuario
646
+ nomina.anulado_en = utc_now()
647
+ nomina.razon_anulacion = razon
648
+
649
+ # Create audit log
650
+ crear_log_auditoria_nomina(
651
+ nomina=nomina,
652
+ accion="cancelled",
653
+ usuario=usuario,
654
+ descripcion=f"Anuló nómina del período {nomina.periodo_inicio} al {nomina.periodo_fin} - Razón: {razon}",
655
+ estado_anterior=estado_anterior,
656
+ estado_nuevo=NominaEstado.ANULADO,
657
+ )
658
+
659
+ return True
660
+
661
+
662
+ # ============================================================================
663
+ # REGLA CALCULO AUDIT FUNCTIONS
664
+ # ============================================================================
665
+
666
+
667
+ def crear_log_auditoria_regla_calculo(
668
+ regla_calculo: ReglaCalculo,
669
+ accion: str,
670
+ usuario: str,
671
+ descripcion: Optional[str] = None,
672
+ cambios: Optional[Dict[str, Any]] = None,
673
+ estado_anterior: Optional[str] = None,
674
+ estado_nuevo: Optional[str] = None,
675
+ ) -> ReglaCalculoAuditLog:
676
+ """Create an audit log entry for a calculation rule change.
677
+
678
+ Args:
679
+ regla_calculo: The calculation rule that was changed
680
+ accion: Action performed (created, updated, approved, rejected, etc.)
681
+ usuario: Username who performed the action
682
+ descripcion: Human-readable description of the change
683
+ cambios: Dictionary of field-level changes
684
+ estado_anterior: Previous approval status
685
+ estado_nuevo: New approval status
686
+
687
+ Returns:
688
+ The created audit log entry
689
+ """
690
+ log = ReglaCalculoAuditLog(
691
+ regla_calculo_id=regla_calculo.id,
692
+ accion=accion,
693
+ usuario=usuario,
694
+ descripcion=descripcion,
695
+ cambios=cambios or {},
696
+ estado_anterior=estado_anterior,
697
+ estado_nuevo=estado_nuevo,
698
+ )
699
+
700
+ db.session.add(log)
701
+ return log
702
+
703
+
704
+ def aprobar_regla_calculo(regla_calculo: ReglaCalculo, usuario: str) -> bool:
705
+ """Approve a calculation rule.
706
+
707
+ Changes status from 'borrador' to 'aprobado' and records approval information.
708
+
709
+ Args:
710
+ regla_calculo: The calculation rule to approve
711
+ usuario: Username who is approving
712
+
713
+ Returns:
714
+ True if approved successfully, False if already approved or invalid
715
+ """
716
+ if regla_calculo.estado_aprobacion == EstadoAprobacion.APROBADO:
717
+ return False
718
+
719
+ estado_anterior = regla_calculo.estado_aprobacion
720
+ regla_calculo.estado_aprobacion = EstadoAprobacion.APROBADO
721
+ regla_calculo.aprobado_por = usuario
722
+ regla_calculo.aprobado_en = utc_now()
723
+
724
+ # Create audit log
725
+ crear_log_auditoria_regla_calculo(
726
+ regla_calculo=regla_calculo,
727
+ accion="approved",
728
+ usuario=usuario,
729
+ descripcion=(
730
+ "Aprobó regla de cálculo "
731
+ + f"'{regla_calculo.nombre}' (código: {regla_calculo.codigo}, "
732
+ + f"versión: {regla_calculo.version})"
733
+ ),
734
+ estado_anterior=estado_anterior,
735
+ estado_nuevo=EstadoAprobacion.APROBADO,
736
+ )
737
+
738
+ return True
739
+
740
+
741
+ def rechazar_regla_calculo(
742
+ regla_calculo: ReglaCalculo,
743
+ usuario: str,
744
+ razon: Optional[str] = None,
745
+ ) -> bool:
746
+ """Reject a calculation rule (keep as draft).
747
+
748
+ Args:
749
+ regla_calculo: The calculation rule to reject
750
+ usuario: Username who is rejecting
751
+ razon: Reason for rejection
752
+
753
+ Returns:
754
+ True if rejected successfully
755
+ """
756
+ estado_anterior = regla_calculo.estado_aprobacion
757
+ regla_calculo.estado_aprobacion = EstadoAprobacion.BORRADOR
758
+ regla_calculo.aprobado_por = None
759
+ regla_calculo.aprobado_en = None
760
+
761
+ # Create audit log
762
+ descripcion = (
763
+ f"Rechazó regla de cálculo '{regla_calculo.nombre}' "
764
+ + f"(código: {regla_calculo.codigo}, versión: {regla_calculo.version})"
765
+ )
766
+ if razon:
767
+ descripcion += f" - Razón: {razon}"
768
+
769
+ crear_log_auditoria_regla_calculo(
770
+ regla_calculo=regla_calculo,
771
+ accion="rejected",
772
+ usuario=usuario,
773
+ descripcion=descripcion,
774
+ estado_anterior=estado_anterior,
775
+ estado_nuevo=EstadoAprobacion.BORRADOR,
776
+ )
777
+
778
+ return True
779
+
780
+
781
+ def marcar_regla_calculo_como_borrador_si_editada(
782
+ regla_calculo: ReglaCalculo,
783
+ usuario: str,
784
+ cambios: Dict[str, Any],
785
+ ) -> None:
786
+ """Mark calculation rule as draft if it was edited while approved.
787
+
788
+ When an approved calculation rule is edited, it must return to draft status
789
+ unless it was created by a plugin.
790
+
791
+ Args:
792
+ regla_calculo: The calculation rule that was edited
793
+ usuario: Username who edited
794
+ cambios: Dictionary of changes made
795
+ """
796
+ # Don't change status if created by plugin
797
+ if regla_calculo.creado_por_plugin:
798
+ return
799
+
800
+ # If currently approved, mark as draft
801
+ if regla_calculo.estado_aprobacion == EstadoAprobacion.APROBADO:
802
+ estado_anterior = regla_calculo.estado_aprobacion
803
+ regla_calculo.estado_aprobacion = EstadoAprobacion.BORRADOR
804
+ regla_calculo.aprobado_por = None
805
+ regla_calculo.aprobado_en = None
806
+
807
+ # Create audit log
808
+ descripcion_cambios = generar_descripcion_cambios(cambios)
809
+
810
+ crear_log_auditoria_regla_calculo(
811
+ regla_calculo=regla_calculo,
812
+ accion="updated",
813
+ usuario=usuario,
814
+ descripcion=(
815
+ f"Editó regla de cálculo '{regla_calculo.nombre}' - "
816
+ + f"{descripcion_cambios}. Estado cambiado a borrador."
817
+ ),
818
+ cambios=cambios,
819
+ estado_anterior=estado_anterior,
820
+ estado_nuevo=EstadoAprobacion.BORRADOR,
821
+ )
822
+
823
+
824
+ def obtener_reglas_calculo_en_borrador(planilla_id: str) -> list:
825
+ """Get all draft calculation rules associated with a planilla.
826
+
827
+ Args:
828
+ planilla_id: ID of the planilla
829
+
830
+ Returns:
831
+ List of draft calculation rules
832
+ """
833
+ from coati_payroll.model import PlanillaReglaCalculo
834
+
835
+ reglas_borrador = (
836
+ db.session.query(ReglaCalculo)
837
+ .join(PlanillaReglaCalculo)
838
+ .filter(
839
+ PlanillaReglaCalculo.planilla_id == planilla_id,
840
+ ReglaCalculo.estado_aprobacion == EstadoAprobacion.BORRADOR,
841
+ ReglaCalculo.activo == True, # noqa: E712
842
+ )
843
+ .all()
844
+ )
845
+
846
+ return reglas_borrador
847
+
848
+
849
+ def validar_configuracion_nomina(planilla_id: str) -> Dict[str, Any]:
850
+ """Validate payroll configuration before execution.
851
+
852
+ Checks for draft concepts and calculation rules that may affect payroll accuracy.
853
+ Returns warnings but does not prevent execution (allows test runs).
854
+
855
+ Args:
856
+ planilla_id: ID of the planilla
857
+
858
+ Returns:
859
+ Dictionary with validation results:
860
+ {
861
+ "tiene_advertencias": bool,
862
+ "advertencias": list of warning messages,
863
+ "conceptos_borrador": dict with draft concepts,
864
+ "reglas_borrador": list of draft calculation rules
865
+ }
866
+ """
867
+ advertencias = []
868
+
869
+ # Check for draft concepts
870
+ conceptos_borrador = obtener_conceptos_en_borrador(planilla_id)
871
+
872
+ if conceptos_borrador["percepciones"]:
873
+ percepciones_nombres = [p.nombre for p in conceptos_borrador["percepciones"]]
874
+ advertencias.append(
875
+ f"⚠️ Hay {len(percepciones_nombres)} percepción(es) en estado BORRADOR: {', '.join(percepciones_nombres)}"
876
+ )
877
+
878
+ if conceptos_borrador["deducciones"]:
879
+ deducciones_nombres = [d.nombre for d in conceptos_borrador["deducciones"]]
880
+ advertencias.append(
881
+ f"⚠️ Hay {len(deducciones_nombres)} deducción(es) en estado BORRADOR: {', '.join(deducciones_nombres)}"
882
+ )
883
+
884
+ if conceptos_borrador["prestaciones"]:
885
+ prestaciones_nombres = [p.nombre for p in conceptos_borrador["prestaciones"]]
886
+ advertencias.append(
887
+ f"⚠️ Hay {len(prestaciones_nombres)} prestación(es) en estado BORRADOR: {', '.join(prestaciones_nombres)}"
888
+ )
889
+
890
+ # Check for draft calculation rules
891
+ reglas_borrador = obtener_reglas_calculo_en_borrador(planilla_id)
892
+
893
+ if reglas_borrador:
894
+ reglas_nombres = [f"{r.nombre} (v{r.version})" for r in reglas_borrador]
895
+ advertencias.append(
896
+ f"⚠️ Hay {len(reglas_nombres)} regla(s) de cálculo en estado BORRADOR: {', '.join(reglas_nombres)}"
897
+ )
898
+
899
+ return {
900
+ "tiene_advertencias": bool(advertencias),
901
+ "advertencias": advertencias,
902
+ "conceptos_borrador": conceptos_borrador,
903
+ "reglas_borrador": reglas_borrador,
904
+ }