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,708 @@
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
+ """Service for generating accounting vouchers from payroll calculations."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from decimal import Decimal
19
+ from typing import Any
20
+ from collections import defaultdict
21
+ from datetime import date
22
+
23
+ from coati_payroll.model import (
24
+ db,
25
+ Nomina,
26
+ NominaEmpleado,
27
+ NominaDetalle,
28
+ Planilla,
29
+ Percepcion,
30
+ Deduccion,
31
+ Prestacion,
32
+ Adelanto,
33
+ ComprobanteContable,
34
+ ComprobanteContableLinea,
35
+ )
36
+
37
+
38
+ class AccountingVoucherService:
39
+ """Service for generating accounting vouchers from payroll calculations."""
40
+
41
+ def __init__(self, session):
42
+ self.session = session
43
+
44
+ def validate_accounting_configuration(self, planilla: Planilla) -> tuple[bool, list[str]]:
45
+ """Validate that all accounting configuration is complete.
46
+
47
+ Args:
48
+ planilla: The planilla to validate
49
+
50
+ Returns:
51
+ Tuple of (is_valid, list of warnings)
52
+ """
53
+ warnings = []
54
+
55
+ # Check base salary accounts
56
+ if not planilla.codigo_cuenta_debe_salario:
57
+ warnings.append("Falta configurar la cuenta de débito para salario básico en la planilla")
58
+ if not planilla.codigo_cuenta_haber_salario:
59
+ warnings.append("Falta configurar la cuenta de crédito para salario básico en la planilla")
60
+
61
+ # Check percepciones
62
+ percepciones = (
63
+ self.session.execute(
64
+ db.select(Percepcion)
65
+ .join(Percepcion.planillas)
66
+ .filter(db.text("planilla_ingreso.planilla_id = :planilla_id"))
67
+ .params(planilla_id=planilla.id)
68
+ )
69
+ .scalars()
70
+ .all()
71
+ )
72
+
73
+ for percepcion in percepciones:
74
+ if percepcion.contabilizable:
75
+ if not percepcion.codigo_cuenta_debe:
76
+ warnings.append(
77
+ f"Percepción '{percepcion.nombre}' ({percepcion.codigo}) no tiene cuenta de débito configurada"
78
+ )
79
+ if not percepcion.codigo_cuenta_haber:
80
+ warnings.append(
81
+ f"Percepción '{percepcion.nombre}' ({percepcion.codigo}) no tiene cuenta de crédito configurada"
82
+ )
83
+
84
+ # Check deducciones
85
+ deducciones = (
86
+ self.session.execute(
87
+ db.select(Deduccion)
88
+ .join(Deduccion.planillas)
89
+ .filter(db.text("planilla_deduccion.planilla_id = :planilla_id"))
90
+ .params(planilla_id=planilla.id)
91
+ )
92
+ .scalars()
93
+ .all()
94
+ )
95
+
96
+ for deduccion in deducciones:
97
+ if deduccion.contabilizable:
98
+ if not deduccion.codigo_cuenta_debe:
99
+ warnings.append(
100
+ f"Deducción '{deduccion.nombre}' ({deduccion.codigo}) no tiene cuenta de débito configurada"
101
+ )
102
+ if not deduccion.codigo_cuenta_haber:
103
+ warnings.append(
104
+ f"Deducción '{deduccion.nombre}' ({deduccion.codigo}) no tiene cuenta de crédito configurada"
105
+ )
106
+
107
+ # Check prestaciones
108
+ prestaciones = (
109
+ self.session.execute(
110
+ db.select(Prestacion)
111
+ .join(Prestacion.planillas)
112
+ .filter(db.text("planilla_prestacion.planilla_id = :planilla_id"))
113
+ .params(planilla_id=planilla.id)
114
+ )
115
+ .scalars()
116
+ .all()
117
+ )
118
+
119
+ for prestacion in prestaciones:
120
+ if prestacion.contabilizable:
121
+ if not prestacion.codigo_cuenta_debe:
122
+ warnings.append(
123
+ f"Prestación '{prestacion.nombre}' ({prestacion.codigo}) no tiene cuenta de débito configurada"
124
+ )
125
+ if not prestacion.codigo_cuenta_haber:
126
+ warnings.append(
127
+ f"Prestación '{prestacion.nombre}' ({prestacion.codigo}) no tiene cuenta de crédito configurada"
128
+ )
129
+
130
+ is_valid = len(warnings) == 0
131
+ return is_valid, warnings
132
+
133
+ def generate_accounting_voucher(
134
+ self, nomina: Nomina, planilla: Planilla, fecha_calculo: date = None, usuario: str = None
135
+ ) -> ComprobanteContable:
136
+ """Generate accounting voucher for a nomina with individual lines per employee.
137
+
138
+ Args:
139
+ nomina: The nomina to generate voucher for
140
+ planilla: The planilla configuration
141
+ fecha_calculo: Calculation date (defaults to nomina periodo_fin)
142
+ usuario: User generating/regenerating the voucher
143
+
144
+ Returns:
145
+ ComprobanteContable with generated line entries
146
+ """
147
+ from datetime import datetime, timezone
148
+
149
+ # Validate configuration
150
+ is_valid, warnings = self.validate_accounting_configuration(planilla)
151
+
152
+ # Use nomina's calculation date or periodo_fin
153
+ if fecha_calculo is None:
154
+ fecha_calculo = nomina.fecha_calculo_original or nomina.periodo_fin
155
+
156
+ # Generate voucher concept
157
+ concepto = (
158
+ f"Nómina {planilla.nombre}"
159
+ + f" - Período {nomina.periodo_inicio.strftime('%d/%m/%Y')} al "
160
+ + f"{nomina.periodo_fin.strftime('%d/%m/%Y')}"
161
+ )
162
+
163
+ # Get or create comprobante
164
+ comprobante = self.session.execute(
165
+ db.select(ComprobanteContable).filter_by(nomina_id=nomina.id)
166
+ ).scalar_one_or_none()
167
+
168
+ if comprobante:
169
+ # Regenerating - update modification audit trail
170
+ self.session.execute(
171
+ db.delete(ComprobanteContableLinea).where(ComprobanteContableLinea.comprobante_id == comprobante.id)
172
+ )
173
+ self.session.flush()
174
+ # Update header information
175
+ comprobante.fecha_calculo = fecha_calculo
176
+ comprobante.concepto = concepto
177
+ comprobante.moneda_id = planilla.moneda_id
178
+ comprobante.advertencias = warnings
179
+ # Update modification tracking
180
+ comprobante.modificado_por = usuario or nomina.generado_por
181
+ comprobante.fecha_modificacion = datetime.now(timezone.utc)
182
+ comprobante.veces_modificado += 1
183
+ else:
184
+ # Creating new - set initial audit trail
185
+ # Check if nomina is already applied to set aplicado_por
186
+ from coati_payroll.enums import NominaEstado
187
+
188
+ aplicado_por = None
189
+ fecha_aplicacion = None
190
+ if nomina.estado in (NominaEstado.APLICADO, NominaEstado.PAGADO):
191
+ aplicado_por = nomina.aplicado_por or usuario or nomina.generado_por
192
+ fecha_aplicacion = nomina.aplicado_en or datetime.now(timezone.utc)
193
+
194
+ comprobante = ComprobanteContable(
195
+ nomina_id=nomina.id,
196
+ fecha_calculo=fecha_calculo,
197
+ concepto=concepto,
198
+ moneda_id=planilla.moneda_id,
199
+ total_debitos=Decimal("0.00"),
200
+ total_creditos=Decimal("0.00"),
201
+ balance=Decimal("0.00"),
202
+ advertencias=warnings,
203
+ aplicado_por=aplicado_por,
204
+ fecha_aplicacion=fecha_aplicacion,
205
+ veces_modificado=0,
206
+ )
207
+ self.session.add(comprobante)
208
+ self.session.flush()
209
+
210
+ # Get all nomina employees with their details
211
+ nomina_empleados = (
212
+ self.session.execute(db.select(NominaEmpleado).filter_by(nomina_id=nomina.id)).scalars().all()
213
+ )
214
+
215
+ # Accumulate totals
216
+ total_debitos = Decimal("0.00")
217
+ total_creditos = Decimal("0.00")
218
+ orden = 0
219
+
220
+ # Process each employee
221
+ for ne in nomina_empleados:
222
+ empleado = ne.empleado
223
+ centro_costos = ne.centro_costos_snapshot or empleado.centro_costos
224
+ empleado_nombre_completo = f"{empleado.primer_nombre} {empleado.primer_apellido}"
225
+
226
+ # 1. Base Salary Accounting
227
+ # Always generate lines even if accounts are missing (use NULL for missing accounts)
228
+ salario_base = ne.sueldo_base_historico
229
+
230
+ # Debit: Salary Expense
231
+ orden += 1
232
+ linea_debe = ComprobanteContableLinea(
233
+ comprobante_id=comprobante.id,
234
+ nomina_empleado_id=ne.id,
235
+ empleado_id=empleado.id,
236
+ empleado_codigo=empleado.codigo_empleado,
237
+ empleado_nombre=empleado_nombre_completo,
238
+ codigo_cuenta=planilla.codigo_cuenta_debe_salario, # Can be None if not configured
239
+ descripcion_cuenta=planilla.descripcion_cuenta_debe_salario
240
+ or ("Gasto por Salario" if planilla.codigo_cuenta_debe_salario else None),
241
+ centro_costos=centro_costos,
242
+ tipo_debito_credito="debito",
243
+ debito=salario_base,
244
+ credito=Decimal("0.00"),
245
+ monto_calculado=salario_base,
246
+ concepto="Salario Base",
247
+ tipo_concepto="salario_base",
248
+ concepto_codigo="SALARIO_BASE",
249
+ orden=orden,
250
+ )
251
+ self.session.add(linea_debe)
252
+ total_debitos += salario_base
253
+
254
+ # Credit: Salary Payable
255
+ orden += 1
256
+ linea_haber = ComprobanteContableLinea(
257
+ comprobante_id=comprobante.id,
258
+ nomina_empleado_id=ne.id,
259
+ empleado_id=empleado.id,
260
+ empleado_codigo=empleado.codigo_empleado,
261
+ empleado_nombre=empleado_nombre_completo,
262
+ codigo_cuenta=planilla.codigo_cuenta_haber_salario, # Can be None if not configured
263
+ descripcion_cuenta=planilla.descripcion_cuenta_haber_salario
264
+ or ("Salario por Pagar" if planilla.codigo_cuenta_haber_salario else None),
265
+ centro_costos=centro_costos,
266
+ tipo_debito_credito="credito",
267
+ debito=Decimal("0.00"),
268
+ credito=salario_base,
269
+ monto_calculado=salario_base,
270
+ concepto="Salario Base",
271
+ tipo_concepto="salario_base",
272
+ concepto_codigo="SALARIO_BASE",
273
+ orden=orden,
274
+ )
275
+ self.session.add(linea_haber)
276
+ total_creditos += salario_base
277
+
278
+ # 2. Process Loans and Advances (special treatment)
279
+ # Loans/advances debit salary payable and credit loan control account
280
+ detalles = (
281
+ self.session.execute(
282
+ db.select(NominaDetalle).filter_by(nomina_empleado_id=ne.id).order_by(NominaDetalle.orden)
283
+ )
284
+ .scalars()
285
+ .all()
286
+ )
287
+
288
+ for detalle in detalles:
289
+ # Check if this is a loan/advance deduction
290
+ is_loan_advance = False
291
+ cuenta_control_prestamo = None
292
+
293
+ if detalle.deduccion_id:
294
+ deduccion = self.session.get(Deduccion, detalle.deduccion_id)
295
+ if deduccion:
296
+ # Check if this deduction is associated with loans/advances
297
+ adelantos = (
298
+ self.session.execute(
299
+ db.select(Adelanto).filter_by(empleado_id=empleado.id, deduccion_id=deduccion.id)
300
+ )
301
+ .scalars()
302
+ .all()
303
+ )
304
+ if adelantos:
305
+ is_loan_advance = True
306
+ # Get loan control account from first active loan
307
+ for adelanto in adelantos:
308
+ if adelanto.estado in ("aprobado", "aplicado"):
309
+ cuenta_control_prestamo = adelanto.cuenta_haber
310
+ break
311
+
312
+ if is_loan_advance:
313
+ # Loan/advance: Debit salary payable, Credit loan control
314
+ # Always create both lines even if accounts are NULL
315
+
316
+ # Debit: Salary Payable (same as base salary credit account, can be NULL)
317
+ orden += 1
318
+ linea_debe = ComprobanteContableLinea(
319
+ comprobante_id=comprobante.id,
320
+ nomina_empleado_id=ne.id,
321
+ empleado_id=empleado.id,
322
+ empleado_codigo=empleado.codigo_empleado,
323
+ empleado_nombre=empleado_nombre_completo,
324
+ codigo_cuenta=planilla.codigo_cuenta_haber_salario, # Can be None
325
+ descripcion_cuenta=(
326
+ (planilla.descripcion_cuenta_haber_salario or "Salario por Pagar")
327
+ if planilla.codigo_cuenta_haber_salario
328
+ else None
329
+ ),
330
+ centro_costos=centro_costos,
331
+ tipo_debito_credito="debito",
332
+ debito=detalle.monto,
333
+ credito=Decimal("0.00"),
334
+ monto_calculado=detalle.monto,
335
+ concepto=detalle.descripcion or "Préstamo/Adelanto",
336
+ tipo_concepto="prestamo",
337
+ concepto_codigo=detalle.codigo,
338
+ orden=orden,
339
+ )
340
+ self.session.add(linea_debe)
341
+ total_debitos += detalle.monto
342
+
343
+ # Credit: Loan Control Account (can be NULL)
344
+ orden += 1
345
+ linea_haber = ComprobanteContableLinea(
346
+ comprobante_id=comprobante.id,
347
+ nomina_empleado_id=ne.id,
348
+ empleado_id=empleado.id,
349
+ empleado_codigo=empleado.codigo_empleado,
350
+ empleado_nombre=empleado_nombre_completo,
351
+ codigo_cuenta=cuenta_control_prestamo, # Can be None
352
+ descripcion_cuenta="Cuenta de Control Préstamos/Adelantos" if cuenta_control_prestamo else None,
353
+ centro_costos=centro_costos,
354
+ tipo_debito_credito="credito",
355
+ debito=Decimal("0.00"),
356
+ credito=detalle.monto,
357
+ monto_calculado=detalle.monto,
358
+ concepto=detalle.descripcion or "Préstamo/Adelanto",
359
+ tipo_concepto="prestamo",
360
+ concepto_codigo=detalle.codigo,
361
+ orden=orden,
362
+ )
363
+ self.session.add(linea_haber)
364
+ total_creditos += detalle.monto
365
+
366
+ else:
367
+ # Regular concept - use configured accounts (or NULL if missing)
368
+ if detalle.tipo == "ingreso" and detalle.percepcion_id:
369
+ percepcion = self.session.get(Percepcion, detalle.percepcion_id)
370
+ if percepcion and percepcion.contabilizable:
371
+ # Always create debit line (even if account is NULL)
372
+ orden += 1
373
+ linea_debe = ComprobanteContableLinea(
374
+ comprobante_id=comprobante.id,
375
+ nomina_empleado_id=ne.id,
376
+ empleado_id=empleado.id,
377
+ empleado_codigo=empleado.codigo_empleado,
378
+ empleado_nombre=empleado_nombre_completo,
379
+ codigo_cuenta=percepcion.codigo_cuenta_debe, # Can be None
380
+ descripcion_cuenta=(
381
+ (percepcion.descripcion_cuenta_debe or percepcion.nombre)
382
+ if percepcion.codigo_cuenta_debe
383
+ else None
384
+ ),
385
+ centro_costos=centro_costos,
386
+ tipo_debito_credito="debito",
387
+ debito=detalle.monto,
388
+ credito=Decimal("0.00"),
389
+ monto_calculado=detalle.monto,
390
+ concepto=detalle.descripcion or percepcion.nombre,
391
+ tipo_concepto="percepcion",
392
+ concepto_codigo=percepcion.codigo,
393
+ orden=orden,
394
+ )
395
+ self.session.add(linea_debe)
396
+ total_debitos += detalle.monto
397
+
398
+ # Always create credit line (even if account is NULL)
399
+ orden += 1
400
+ linea_haber = ComprobanteContableLinea(
401
+ comprobante_id=comprobante.id,
402
+ nomina_empleado_id=ne.id,
403
+ empleado_id=empleado.id,
404
+ empleado_codigo=empleado.codigo_empleado,
405
+ empleado_nombre=empleado_nombre_completo,
406
+ codigo_cuenta=percepcion.codigo_cuenta_haber, # Can be None
407
+ descripcion_cuenta=(
408
+ (percepcion.descripcion_cuenta_haber or percepcion.nombre)
409
+ if percepcion.codigo_cuenta_haber
410
+ else None
411
+ ),
412
+ centro_costos=centro_costos,
413
+ tipo_debito_credito="credito",
414
+ debito=Decimal("0.00"),
415
+ credito=detalle.monto,
416
+ monto_calculado=detalle.monto,
417
+ concepto=detalle.descripcion or percepcion.nombre,
418
+ tipo_concepto="percepcion",
419
+ concepto_codigo=percepcion.codigo,
420
+ orden=orden,
421
+ )
422
+ self.session.add(linea_haber)
423
+ total_creditos += detalle.monto
424
+
425
+ elif detalle.tipo == "deduccion" and detalle.deduccion_id:
426
+ deduccion = self.session.get(Deduccion, detalle.deduccion_id)
427
+ if deduccion and deduccion.contabilizable:
428
+ # Always create debit line (even if account is NULL)
429
+ orden += 1
430
+ linea_debe = ComprobanteContableLinea(
431
+ comprobante_id=comprobante.id,
432
+ nomina_empleado_id=ne.id,
433
+ empleado_id=empleado.id,
434
+ empleado_codigo=empleado.codigo_empleado,
435
+ empleado_nombre=empleado_nombre_completo,
436
+ codigo_cuenta=deduccion.codigo_cuenta_debe, # Can be None
437
+ descripcion_cuenta=(
438
+ (deduccion.descripcion_cuenta_debe or deduccion.nombre)
439
+ if deduccion.codigo_cuenta_debe
440
+ else None
441
+ ),
442
+ centro_costos=centro_costos,
443
+ tipo_debito_credito="debito",
444
+ debito=detalle.monto,
445
+ credito=Decimal("0.00"),
446
+ monto_calculado=detalle.monto,
447
+ concepto=detalle.descripcion or deduccion.nombre,
448
+ tipo_concepto="deduccion",
449
+ concepto_codigo=deduccion.codigo,
450
+ orden=orden,
451
+ )
452
+ self.session.add(linea_debe)
453
+ total_debitos += detalle.monto
454
+
455
+ # Always create credit line (even if account is NULL)
456
+ orden += 1
457
+ linea_haber = ComprobanteContableLinea(
458
+ comprobante_id=comprobante.id,
459
+ nomina_empleado_id=ne.id,
460
+ empleado_id=empleado.id,
461
+ empleado_codigo=empleado.codigo_empleado,
462
+ empleado_nombre=empleado_nombre_completo,
463
+ codigo_cuenta=deduccion.codigo_cuenta_haber, # Can be None
464
+ descripcion_cuenta=(
465
+ (deduccion.descripcion_cuenta_haber or deduccion.nombre)
466
+ if deduccion.codigo_cuenta_haber
467
+ else None
468
+ ),
469
+ centro_costos=centro_costos,
470
+ tipo_debito_credito="credito",
471
+ debito=Decimal("0.00"),
472
+ credito=detalle.monto,
473
+ monto_calculado=detalle.monto,
474
+ concepto=detalle.descripcion or deduccion.nombre,
475
+ tipo_concepto="deduccion",
476
+ concepto_codigo=deduccion.codigo,
477
+ orden=orden,
478
+ )
479
+ self.session.add(linea_haber)
480
+ total_creditos += detalle.monto
481
+
482
+ elif detalle.tipo == "prestacion" and detalle.prestacion_id:
483
+ prestacion = self.session.get(Prestacion, detalle.prestacion_id)
484
+ if prestacion and prestacion.contabilizable:
485
+ # Always create debit line (even if account is NULL)
486
+ orden += 1
487
+ linea_debe = ComprobanteContableLinea(
488
+ comprobante_id=comprobante.id,
489
+ nomina_empleado_id=ne.id,
490
+ empleado_id=empleado.id,
491
+ empleado_codigo=empleado.codigo_empleado,
492
+ empleado_nombre=empleado_nombre_completo,
493
+ codigo_cuenta=prestacion.codigo_cuenta_debe, # Can be None
494
+ descripcion_cuenta=(
495
+ (prestacion.descripcion_cuenta_debe or prestacion.nombre)
496
+ if prestacion.codigo_cuenta_debe
497
+ else None
498
+ ),
499
+ centro_costos=centro_costos,
500
+ tipo_debito_credito="debito",
501
+ debito=detalle.monto,
502
+ credito=Decimal("0.00"),
503
+ monto_calculado=detalle.monto,
504
+ concepto=detalle.descripcion or prestacion.nombre,
505
+ tipo_concepto="prestacion",
506
+ concepto_codigo=prestacion.codigo,
507
+ orden=orden,
508
+ )
509
+ self.session.add(linea_debe)
510
+ total_debitos += detalle.monto
511
+
512
+ # Always create credit line (even if account is NULL)
513
+ orden += 1
514
+ linea_haber = ComprobanteContableLinea(
515
+ comprobante_id=comprobante.id,
516
+ nomina_empleado_id=ne.id,
517
+ empleado_id=empleado.id,
518
+ empleado_codigo=empleado.codigo_empleado,
519
+ empleado_nombre=empleado_nombre_completo,
520
+ codigo_cuenta=prestacion.codigo_cuenta_haber, # Can be None
521
+ descripcion_cuenta=(
522
+ (prestacion.descripcion_cuenta_haber or prestacion.nombre)
523
+ if prestacion.codigo_cuenta_haber
524
+ else None
525
+ ),
526
+ centro_costos=centro_costos,
527
+ tipo_debito_credito="credito",
528
+ debito=Decimal("0.00"),
529
+ credito=detalle.monto,
530
+ monto_calculado=detalle.monto,
531
+ concepto=detalle.descripcion or prestacion.nombre,
532
+ tipo_concepto="prestacion",
533
+ concepto_codigo=prestacion.codigo,
534
+ orden=orden,
535
+ )
536
+ self.session.add(linea_haber)
537
+ total_creditos += detalle.monto
538
+
539
+ # Calculate balance (should be 0 for balanced voucher)
540
+ balance = total_debitos - total_creditos
541
+
542
+ # Validate balance
543
+ if balance != Decimal("0.00"):
544
+ balance_warning = (
545
+ f"ADVERTENCIA: El comprobante no está balanceado. "
546
+ f"Débitos: {total_debitos}, Créditos: {total_creditos}, "
547
+ f"Diferencia: {abs(balance)}"
548
+ )
549
+ if balance_warning not in warnings:
550
+ warnings.append(balance_warning)
551
+
552
+ # Update comprobante totals
553
+ comprobante.total_debitos = total_debitos
554
+ comprobante.total_creditos = total_creditos
555
+ comprobante.balance = balance
556
+ comprobante.advertencias = warnings
557
+
558
+ return comprobante
559
+
560
+ def summarize_voucher(self, comprobante: ComprobanteContable) -> list[dict[str, Any]]:
561
+ """Summarize voucher lines by account and cost center with netting.
562
+
563
+ Groups lines by (codigo_cuenta, centro_costos) and nets debits/credits.
564
+ If same account+cost center has both debits and credits, they are netted
565
+ and only one line with the net amount is shown.
566
+
567
+ Lines with NULL accounts are skipped from summarization as they indicate
568
+ incomplete accounting configuration.
569
+
570
+ Args:
571
+ comprobante: The comprobante to summarize
572
+
573
+ Returns:
574
+ List of summarized entries sorted by account code
575
+
576
+ Raises:
577
+ ValueError: If comprobante has NULL accounts (incomplete configuration)
578
+ """
579
+ # Dictionary to accumulate by (account, cost_center)
580
+ summary_dict: dict[tuple[str, str | None], dict[str, Any]] = defaultdict(
581
+ lambda: {"debito": Decimal("0.00"), "credito": Decimal("0.00"), "descripcion": ""}
582
+ )
583
+
584
+ # Get all lines
585
+ lineas = (
586
+ self.session.execute(
587
+ db.select(ComprobanteContableLinea)
588
+ .filter_by(comprobante_id=comprobante.id)
589
+ .order_by(ComprobanteContableLinea.orden)
590
+ )
591
+ .scalars()
592
+ .all()
593
+ )
594
+
595
+ # Check for NULL accounts and raise error if found
596
+ null_account_lines = [linea for linea in lineas if linea.codigo_cuenta is None]
597
+ if null_account_lines:
598
+ raise ValueError(
599
+ "No se puede generar comprobante sumarizado: existen líneas con cuentas contables sin configurar. "
600
+ "Por favor configure todas las cuentas contables o utilice el comprobante de auditoría."
601
+ )
602
+
603
+ # Accumulate by account + cost center (only lines with valid accounts)
604
+ for linea in lineas:
605
+ if linea.codigo_cuenta is None:
606
+ continue # Skip NULL accounts
607
+
608
+ key = (linea.codigo_cuenta, linea.centro_costos)
609
+ summary_dict[key]["debito"] += linea.debito
610
+ summary_dict[key]["credito"] += linea.credito
611
+ # Use first description found for this account
612
+ if not summary_dict[key]["descripcion"]:
613
+ summary_dict[key]["descripcion"] = linea.descripcion_cuenta or ""
614
+
615
+ # Create summarized entries with netting
616
+ summarized_entries = []
617
+ for (codigo_cuenta, centro_costos), amounts in summary_dict.items():
618
+ debito = amounts["debito"]
619
+ credito = amounts["credito"]
620
+
621
+ # Net debits and credits
622
+ if debito > credito:
623
+ monto_neto = debito - credito
624
+ summarized_entries.append(
625
+ {
626
+ "codigo_cuenta": codigo_cuenta,
627
+ "descripcion": amounts["descripcion"],
628
+ "centro_costos": centro_costos,
629
+ "debito": monto_neto,
630
+ "credito": Decimal("0.00"),
631
+ }
632
+ )
633
+ elif credito > debito:
634
+ monto_neto = credito - debito
635
+ summarized_entries.append(
636
+ {
637
+ "codigo_cuenta": codigo_cuenta,
638
+ "descripcion": amounts["descripcion"],
639
+ "centro_costos": centro_costos,
640
+ "debito": Decimal("0.00"),
641
+ "credito": monto_neto,
642
+ }
643
+ )
644
+ # If debito == credito, they cancel out completely, so line is excluded from summary
645
+ # This is intentional: zero-balance entries don't need to appear in the summarized voucher
646
+
647
+ # Sort by account code and cost center
648
+ summarized_entries.sort(key=lambda x: (x["codigo_cuenta"], x["centro_costos"] or ""))
649
+
650
+ return summarized_entries
651
+
652
+ def get_detailed_voucher_by_employee(self, comprobante: ComprobanteContable) -> list[dict[str, Any]]:
653
+ """Get detailed voucher lines grouped by employee for audit purposes.
654
+
655
+ Args:
656
+ comprobante: The comprobante to get details for
657
+
658
+ Returns:
659
+ List of entries grouped by employee with all their accounting lines
660
+ """
661
+ detailed_entries = []
662
+
663
+ # Get all lines grouped by employee using the denormalized employee info
664
+ lineas = (
665
+ self.session.execute(
666
+ db.select(ComprobanteContableLinea)
667
+ .filter_by(comprobante_id=comprobante.id)
668
+ .order_by(ComprobanteContableLinea.empleado_codigo, ComprobanteContableLinea.orden)
669
+ )
670
+ .scalars()
671
+ .all()
672
+ )
673
+
674
+ # Group by employee
675
+ current_empleado_codigo = None
676
+ current_entry = None
677
+
678
+ for linea in lineas:
679
+ if linea.empleado_codigo != current_empleado_codigo:
680
+ # New employee, save previous and start new
681
+ if current_entry:
682
+ detailed_entries.append(current_entry)
683
+
684
+ current_empleado_codigo = linea.empleado_codigo
685
+ current_entry = {
686
+ "empleado_codigo": linea.empleado_codigo,
687
+ "empleado_nombre": linea.empleado_nombre,
688
+ "centro_costos": linea.centro_costos,
689
+ "lineas": [],
690
+ }
691
+
692
+ # Add line to current employee
693
+ current_entry["lineas"].append(
694
+ {
695
+ "concepto": linea.concepto,
696
+ "tipo_concepto": linea.tipo_concepto,
697
+ "codigo_cuenta": linea.codigo_cuenta,
698
+ "descripcion_cuenta": linea.descripcion_cuenta,
699
+ "debito": linea.debito,
700
+ "credito": linea.credito,
701
+ }
702
+ )
703
+
704
+ # Don't forget the last employee
705
+ if current_entry:
706
+ detailed_entries.append(current_entry)
707
+
708
+ return detailed_entries