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.
- coati_payroll/__init__.py +415 -0
- coati_payroll/app.py +95 -0
- coati_payroll/audit_helpers.py +904 -0
- coati_payroll/auth.py +123 -0
- coati_payroll/cli.py +1318 -0
- coati_payroll/config.py +219 -0
- coati_payroll/demo_data.py +813 -0
- coati_payroll/enums.py +278 -0
- coati_payroll/forms.py +1769 -0
- coati_payroll/formula_engine/__init__.py +81 -0
- coati_payroll/formula_engine/ast/__init__.py +110 -0
- coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
- coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
- coati_payroll/formula_engine/ast/safe_operators.py +131 -0
- coati_payroll/formula_engine/ast/type_converter.py +172 -0
- coati_payroll/formula_engine/data_sources.py +752 -0
- coati_payroll/formula_engine/engine.py +247 -0
- coati_payroll/formula_engine/exceptions.py +52 -0
- coati_payroll/formula_engine/execution/__init__.py +24 -0
- coati_payroll/formula_engine/execution/execution_context.py +52 -0
- coati_payroll/formula_engine/execution/step_executor.py +62 -0
- coati_payroll/formula_engine/execution/variable_store.py +59 -0
- coati_payroll/formula_engine/novelty_codes.py +206 -0
- coati_payroll/formula_engine/results/__init__.py +20 -0
- coati_payroll/formula_engine/results/execution_result.py +59 -0
- coati_payroll/formula_engine/steps/__init__.py +30 -0
- coati_payroll/formula_engine/steps/assignment_step.py +71 -0
- coati_payroll/formula_engine/steps/base_step.py +48 -0
- coati_payroll/formula_engine/steps/calculation_step.py +42 -0
- coati_payroll/formula_engine/steps/conditional_step.py +122 -0
- coati_payroll/formula_engine/steps/step_factory.py +58 -0
- coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
- coati_payroll/formula_engine/tables/__init__.py +24 -0
- coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
- coati_payroll/formula_engine/tables/table_lookup.py +161 -0
- coati_payroll/formula_engine/tables/tax_table.py +32 -0
- coati_payroll/formula_engine/validation/__init__.py +24 -0
- coati_payroll/formula_engine/validation/schema_validator.py +37 -0
- coati_payroll/formula_engine/validation/security_validator.py +52 -0
- coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
- coati_payroll/formula_engine_examples.py +153 -0
- coati_payroll/i18n.py +54 -0
- coati_payroll/initial_data.py +613 -0
- coati_payroll/interes_engine.py +450 -0
- coati_payroll/liquidacion_engine/__init__.py +25 -0
- coati_payroll/liquidacion_engine/engine.py +267 -0
- coati_payroll/locale_config.py +165 -0
- coati_payroll/log.py +138 -0
- coati_payroll/model.py +2410 -0
- coati_payroll/nomina_engine/__init__.py +87 -0
- coati_payroll/nomina_engine/calculators/__init__.py +30 -0
- coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
- coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
- coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
- coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
- coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
- coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
- coati_payroll/nomina_engine/domain/__init__.py +27 -0
- coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
- coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
- coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
- coati_payroll/nomina_engine/engine.py +188 -0
- coati_payroll/nomina_engine/processors/__init__.py +28 -0
- coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
- coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
- coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
- coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
- coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
- coati_payroll/nomina_engine/repositories/__init__.py +32 -0
- coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
- coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
- coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
- coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
- coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
- coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
- coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
- coati_payroll/nomina_engine/results/__init__.py +24 -0
- coati_payroll/nomina_engine/results/error_result.py +28 -0
- coati_payroll/nomina_engine/results/payroll_result.py +53 -0
- coati_payroll/nomina_engine/results/validation_result.py +39 -0
- coati_payroll/nomina_engine/services/__init__.py +22 -0
- coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
- coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
- coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
- coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
- coati_payroll/nomina_engine/validators/__init__.py +31 -0
- coati_payroll/nomina_engine/validators/base_validator.py +48 -0
- coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
- coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
- coati_payroll/nomina_engine/validators/period_validator.py +44 -0
- coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
- coati_payroll/plugin_manager.py +176 -0
- coati_payroll/queue/__init__.py +33 -0
- coati_payroll/queue/driver.py +127 -0
- coati_payroll/queue/drivers/__init__.py +22 -0
- coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
- coati_payroll/queue/drivers/huey_driver.py +390 -0
- coati_payroll/queue/drivers/noop_driver.py +54 -0
- coati_payroll/queue/selector.py +121 -0
- coati_payroll/queue/tasks.py +764 -0
- coati_payroll/rate_limiting.py +83 -0
- coati_payroll/rbac.py +183 -0
- coati_payroll/report_engine.py +512 -0
- coati_payroll/report_export.py +208 -0
- coati_payroll/schema_validator.py +167 -0
- coati_payroll/security.py +77 -0
- coati_payroll/static/styles.css +1044 -0
- coati_payroll/system_reports.py +573 -0
- coati_payroll/templates/auth/login.html +189 -0
- coati_payroll/templates/base.html +283 -0
- coati_payroll/templates/index.html +227 -0
- coati_payroll/templates/macros.html +146 -0
- coati_payroll/templates/modules/calculation_rule/form.html +78 -0
- coati_payroll/templates/modules/calculation_rule/index.html +102 -0
- coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
- coati_payroll/templates/modules/config_calculos/index.html +44 -0
- coati_payroll/templates/modules/configuracion/index.html +90 -0
- coati_payroll/templates/modules/currency/form.html +47 -0
- coati_payroll/templates/modules/currency/index.html +64 -0
- coati_payroll/templates/modules/custom_field/form.html +62 -0
- coati_payroll/templates/modules/custom_field/index.html +78 -0
- coati_payroll/templates/modules/deduccion/form.html +1 -0
- coati_payroll/templates/modules/deduccion/index.html +1 -0
- coati_payroll/templates/modules/employee/form.html +254 -0
- coati_payroll/templates/modules/employee/index.html +76 -0
- coati_payroll/templates/modules/empresa/form.html +74 -0
- coati_payroll/templates/modules/empresa/index.html +71 -0
- coati_payroll/templates/modules/exchange_rate/form.html +47 -0
- coati_payroll/templates/modules/exchange_rate/import.html +93 -0
- coati_payroll/templates/modules/exchange_rate/index.html +114 -0
- coati_payroll/templates/modules/liquidacion/index.html +58 -0
- coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
- coati_payroll/templates/modules/liquidacion/ver.html +91 -0
- coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
- coati_payroll/templates/modules/percepcion/form.html +1 -0
- coati_payroll/templates/modules/percepcion/index.html +1 -0
- coati_payroll/templates/modules/planilla/config.html +190 -0
- coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
- coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
- coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
- coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
- coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
- coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
- coati_payroll/templates/modules/planilla/form.html +197 -0
- coati_payroll/templates/modules/planilla/index.html +144 -0
- coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
- coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
- coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
- coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
- coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
- coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
- coati_payroll/templates/modules/plugins/index.html +71 -0
- coati_payroll/templates/modules/prestacion/form.html +1 -0
- coati_payroll/templates/modules/prestacion/index.html +1 -0
- coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
- coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
- coati_payroll/templates/modules/prestamo/approve.html +156 -0
- coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
- coati_payroll/templates/modules/prestamo/detail.html +443 -0
- coati_payroll/templates/modules/prestamo/form.html +203 -0
- coati_payroll/templates/modules/prestamo/index.html +150 -0
- coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
- coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
- coati_payroll/templates/modules/report/admin_index.html +125 -0
- coati_payroll/templates/modules/report/detail.html +129 -0
- coati_payroll/templates/modules/report/execute.html +266 -0
- coati_payroll/templates/modules/report/index.html +95 -0
- coati_payroll/templates/modules/report/permissions.html +64 -0
- coati_payroll/templates/modules/settings/index.html +274 -0
- coati_payroll/templates/modules/shared/concept_form.html +201 -0
- coati_payroll/templates/modules/shared/concept_index.html +145 -0
- coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
- coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
- coati_payroll/templates/modules/user/form.html +65 -0
- coati_payroll/templates/modules/user/index.html +76 -0
- coati_payroll/templates/modules/user/profile.html +81 -0
- coati_payroll/templates/modules/vacation/account_detail.html +149 -0
- coati_payroll/templates/modules/vacation/account_form.html +52 -0
- coati_payroll/templates/modules/vacation/account_index.html +68 -0
- coati_payroll/templates/modules/vacation/dashboard.html +156 -0
- coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
- coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
- coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
- coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
- coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
- coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
- coati_payroll/templates/modules/vacation/policy_form.html +152 -0
- coati_payroll/templates/modules/vacation/policy_index.html +79 -0
- coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
- coati_payroll/vacation_service.py +451 -0
- coati_payroll/version.py +18 -0
- coati_payroll/vistas/__init__.py +64 -0
- coati_payroll/vistas/calculation_rule.py +307 -0
- coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
- coati_payroll/vistas/config_calculos.py +72 -0
- coati_payroll/vistas/configuracion.py +87 -0
- coati_payroll/vistas/constants.py +17 -0
- coati_payroll/vistas/currency.py +112 -0
- coati_payroll/vistas/custom_field.py +120 -0
- coati_payroll/vistas/employee.py +305 -0
- coati_payroll/vistas/empresa.py +153 -0
- coati_payroll/vistas/exchange_rate.py +341 -0
- coati_payroll/vistas/liquidacion.py +205 -0
- coati_payroll/vistas/payroll_concepts.py +580 -0
- coati_payroll/vistas/planilla/__init__.py +38 -0
- coati_payroll/vistas/planilla/association_routes.py +238 -0
- coati_payroll/vistas/planilla/config_routes.py +158 -0
- coati_payroll/vistas/planilla/export_routes.py +175 -0
- coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
- coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
- coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
- coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
- coati_payroll/vistas/planilla/nomina_routes.py +488 -0
- coati_payroll/vistas/planilla/novedad_routes.py +227 -0
- coati_payroll/vistas/planilla/routes.py +145 -0
- coati_payroll/vistas/planilla/services/__init__.py +26 -0
- coati_payroll/vistas/planilla/services/export_service.py +687 -0
- coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
- coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
- coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
- coati_payroll/vistas/planilla/validators/__init__.py +18 -0
- coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
- coati_payroll/vistas/plugins.py +45 -0
- coati_payroll/vistas/prestacion.py +272 -0
- coati_payroll/vistas/prestamo.py +808 -0
- coati_payroll/vistas/report.py +432 -0
- coati_payroll/vistas/settings.py +29 -0
- coati_payroll/vistas/tipo_planilla.py +134 -0
- coati_payroll/vistas/user.py +172 -0
- coati_payroll/vistas/vacation.py +1045 -0
- coati_payroll-0.0.2.dist-info/LICENSE +201 -0
- coati_payroll-0.0.2.dist-info/METADATA +581 -0
- coati_payroll-0.0.2.dist-info/RECORD +243 -0
- coati_payroll-0.0.2.dist-info/WHEEL +5 -0
- coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
- coati_payroll-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,808 @@
|
|
|
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 loans and salary advances (Préstamos y Adelantos).
|
|
15
|
+
|
|
16
|
+
This module handles:
|
|
17
|
+
- Creating loan/advance requests
|
|
18
|
+
- Approving/rejecting loans
|
|
19
|
+
- Viewing payment schedules
|
|
20
|
+
- Exporting payment tables to Excel/PDF
|
|
21
|
+
- Tracking loan balances and payments
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from datetime import date
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from io import BytesIO
|
|
29
|
+
|
|
30
|
+
from flask import (
|
|
31
|
+
Blueprint,
|
|
32
|
+
flash,
|
|
33
|
+
redirect,
|
|
34
|
+
render_template,
|
|
35
|
+
request,
|
|
36
|
+
url_for,
|
|
37
|
+
send_file,
|
|
38
|
+
Response,
|
|
39
|
+
)
|
|
40
|
+
from flask_login import current_user
|
|
41
|
+
|
|
42
|
+
from coati_payroll.model import (
|
|
43
|
+
db,
|
|
44
|
+
Adelanto,
|
|
45
|
+
Empleado,
|
|
46
|
+
Moneda,
|
|
47
|
+
Deduccion,
|
|
48
|
+
AdelantoAbono,
|
|
49
|
+
)
|
|
50
|
+
from coati_payroll.forms import (
|
|
51
|
+
PrestamoForm,
|
|
52
|
+
PrestamoApprovalForm,
|
|
53
|
+
PagoExtraordinarioForm,
|
|
54
|
+
CondonacionForm,
|
|
55
|
+
)
|
|
56
|
+
from coati_payroll.i18n import _
|
|
57
|
+
from coati_payroll.enums import AdelantoEstado, AdelantoTipo
|
|
58
|
+
from coati_payroll.rbac import require_read_access, require_write_access
|
|
59
|
+
|
|
60
|
+
prestamo_bp = Blueprint("prestamo", __name__, url_prefix="/prestamo")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@prestamo_bp.route("/")
|
|
64
|
+
@require_read_access()
|
|
65
|
+
def index():
|
|
66
|
+
"""List all loans and advances with filtering options."""
|
|
67
|
+
# Get filter parameters
|
|
68
|
+
empleado_id = request.args.get("empleado_id", "")
|
|
69
|
+
estado = request.args.get("estado", "")
|
|
70
|
+
tipo = request.args.get("tipo", "")
|
|
71
|
+
|
|
72
|
+
# Build query
|
|
73
|
+
query = db.select(Adelanto).join(Empleado)
|
|
74
|
+
|
|
75
|
+
if empleado_id:
|
|
76
|
+
query = query.filter(Adelanto.empleado_id == empleado_id)
|
|
77
|
+
if estado:
|
|
78
|
+
query = query.filter(Adelanto.estado == estado)
|
|
79
|
+
if tipo:
|
|
80
|
+
query = query.filter(Adelanto.tipo == tipo)
|
|
81
|
+
|
|
82
|
+
# Order by most recent first
|
|
83
|
+
query = query.order_by(Adelanto.fecha_solicitud.desc())
|
|
84
|
+
|
|
85
|
+
prestamos = db.session.execute(query).scalars().all()
|
|
86
|
+
|
|
87
|
+
# Get all employees for filter dropdown
|
|
88
|
+
empleados = (
|
|
89
|
+
db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
|
|
90
|
+
.scalars()
|
|
91
|
+
.all()
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return render_template(
|
|
95
|
+
"modules/prestamo/index.html",
|
|
96
|
+
prestamos=prestamos,
|
|
97
|
+
empleados=empleados,
|
|
98
|
+
filtro_empleado=empleado_id,
|
|
99
|
+
filtro_estado=estado,
|
|
100
|
+
filtro_tipo=tipo,
|
|
101
|
+
estados=AdelantoEstado,
|
|
102
|
+
tipos=AdelantoTipo,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@prestamo_bp.route("/new", methods=["GET", "POST"])
|
|
107
|
+
@require_write_access()
|
|
108
|
+
def new():
|
|
109
|
+
"""Create a new loan or salary advance."""
|
|
110
|
+
form = PrestamoForm()
|
|
111
|
+
|
|
112
|
+
# Populate select fields
|
|
113
|
+
empleados = (
|
|
114
|
+
db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
|
|
115
|
+
.scalars()
|
|
116
|
+
.all()
|
|
117
|
+
)
|
|
118
|
+
form.empleado_id.choices = [
|
|
119
|
+
(emp.id, f"{emp.primer_nombre} {emp.primer_apellido} - {emp.codigo_empleado}") for emp in empleados
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
monedas = db.session.execute(db.select(Moneda).filter_by(activo=True).order_by(Moneda.nombre)).scalars().all()
|
|
123
|
+
form.moneda_id.choices = [(m.id, f"{m.nombre} ({m.codigo})") for m in monedas]
|
|
124
|
+
|
|
125
|
+
deducciones = (
|
|
126
|
+
db.session.execute(db.select(Deduccion).filter_by(activo=True).order_by(Deduccion.nombre)).scalars().all()
|
|
127
|
+
)
|
|
128
|
+
form.deduccion_id.choices = [("", _("-- Sin deducción asociada --"))] + [(d.id, d.nombre) for d in deducciones]
|
|
129
|
+
|
|
130
|
+
if form.validate_on_submit():
|
|
131
|
+
prestamo = Adelanto()
|
|
132
|
+
prestamo.empleado_id = form.empleado_id.data
|
|
133
|
+
prestamo.tipo = form.tipo.data
|
|
134
|
+
prestamo.fecha_solicitud = form.fecha_solicitud.data
|
|
135
|
+
prestamo.monto_solicitado = form.monto_solicitado.data
|
|
136
|
+
prestamo.moneda_id = form.moneda_id.data
|
|
137
|
+
prestamo.cuotas_pactadas = form.cuotas_pactadas.data
|
|
138
|
+
prestamo.tasa_interes = form.tasa_interes.data or Decimal("0.0000")
|
|
139
|
+
prestamo.tipo_interes = form.tipo_interes.data
|
|
140
|
+
prestamo.metodo_amortizacion = form.metodo_amortizacion.data
|
|
141
|
+
prestamo.cuenta_debe = form.cuenta_debe.data
|
|
142
|
+
prestamo.cuenta_haber = form.cuenta_haber.data
|
|
143
|
+
prestamo.motivo = form.motivo.data
|
|
144
|
+
prestamo.estado = AdelantoEstado.BORRADOR
|
|
145
|
+
prestamo.saldo_pendiente = Decimal("0.00") # Will be set upon approval
|
|
146
|
+
prestamo.creado_por = current_user.usuario
|
|
147
|
+
|
|
148
|
+
# Set deduccion_id only if a valid value was selected
|
|
149
|
+
if form.deduccion_id.data:
|
|
150
|
+
prestamo.deduccion_id = form.deduccion_id.data
|
|
151
|
+
|
|
152
|
+
db.session.add(prestamo)
|
|
153
|
+
db.session.commit()
|
|
154
|
+
|
|
155
|
+
flash(
|
|
156
|
+
_("Préstamo/Adelanto creado exitosamente. Estado: Borrador."),
|
|
157
|
+
"success",
|
|
158
|
+
)
|
|
159
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo.id))
|
|
160
|
+
|
|
161
|
+
# Set default values
|
|
162
|
+
if request.method == "GET":
|
|
163
|
+
form.fecha_solicitud.data = date.today()
|
|
164
|
+
form.tipo_interes.data = "ninguno"
|
|
165
|
+
form.tasa_interes.data = Decimal("0.0000")
|
|
166
|
+
form.metodo_amortizacion.data = "frances"
|
|
167
|
+
|
|
168
|
+
return render_template("modules/prestamo/form.html", form=form, prestamo=None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@prestamo_bp.route("/<prestamo_id>")
|
|
172
|
+
@require_read_access()
|
|
173
|
+
def detail(prestamo_id):
|
|
174
|
+
"""View loan details including payment schedule."""
|
|
175
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
176
|
+
if not prestamo:
|
|
177
|
+
flash("Préstamo no encontrado.", "danger")
|
|
178
|
+
return redirect(url_for("prestamo.index"))
|
|
179
|
+
|
|
180
|
+
# Touch relationship to ensure it is loaded before rendering
|
|
181
|
+
prestamo.empleado
|
|
182
|
+
|
|
183
|
+
# Generate payment schedule
|
|
184
|
+
tabla_pago = generar_tabla_pago(prestamo)
|
|
185
|
+
|
|
186
|
+
# Get payment history
|
|
187
|
+
abonos = (
|
|
188
|
+
db.session.execute(
|
|
189
|
+
db.select(AdelantoAbono).filter_by(adelanto_id=prestamo_id).order_by(AdelantoAbono.fecha_abono.desc())
|
|
190
|
+
)
|
|
191
|
+
.scalars()
|
|
192
|
+
.all()
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Get interest journal if loan has interest
|
|
196
|
+
from coati_payroll.model import InteresAdelanto
|
|
197
|
+
|
|
198
|
+
intereses = []
|
|
199
|
+
if prestamo.tasa_interes and prestamo.tasa_interes > 0:
|
|
200
|
+
intereses = (
|
|
201
|
+
db.session.execute(
|
|
202
|
+
db.select(InteresAdelanto)
|
|
203
|
+
.filter_by(adelanto_id=prestamo_id)
|
|
204
|
+
.order_by(InteresAdelanto.fecha_hasta.desc())
|
|
205
|
+
)
|
|
206
|
+
.scalars()
|
|
207
|
+
.all()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return render_template(
|
|
211
|
+
"modules/prestamo/detail.html",
|
|
212
|
+
prestamo=prestamo,
|
|
213
|
+
tabla_pago=tabla_pago,
|
|
214
|
+
abonos=abonos,
|
|
215
|
+
intereses=intereses,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@prestamo_bp.route("/<prestamo_id>/edit", methods=["GET", "POST"])
|
|
220
|
+
@require_write_access()
|
|
221
|
+
def edit(prestamo_id):
|
|
222
|
+
"""Edit a loan (only allowed in draft or pending state)."""
|
|
223
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
224
|
+
if not prestamo:
|
|
225
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
226
|
+
return redirect(url_for("prestamo.index"))
|
|
227
|
+
|
|
228
|
+
# Only allow editing in draft or pending state
|
|
229
|
+
if prestamo.estado not in [AdelantoEstado.BORRADOR, AdelantoEstado.PENDIENTE]:
|
|
230
|
+
flash(
|
|
231
|
+
_("No se puede editar un préstamo en estado '{estado}'.".format(estado=prestamo.estado)),
|
|
232
|
+
"warning",
|
|
233
|
+
)
|
|
234
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
235
|
+
|
|
236
|
+
form = PrestamoForm(obj=prestamo)
|
|
237
|
+
|
|
238
|
+
# Populate select fields
|
|
239
|
+
empleados = (
|
|
240
|
+
db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
|
|
241
|
+
.scalars()
|
|
242
|
+
.all()
|
|
243
|
+
)
|
|
244
|
+
form.empleado_id.choices = [
|
|
245
|
+
(emp.id, f"{emp.primer_nombre} {emp.primer_apellido} - {emp.codigo_empleado}") for emp in empleados
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
monedas = db.session.execute(db.select(Moneda).filter_by(activo=True).order_by(Moneda.nombre)).scalars().all()
|
|
249
|
+
form.moneda_id.choices = [(m.id, f"{m.nombre} ({m.codigo})") for m in monedas]
|
|
250
|
+
|
|
251
|
+
deducciones = (
|
|
252
|
+
db.session.execute(db.select(Deduccion).filter_by(activo=True).order_by(Deduccion.nombre)).scalars().all()
|
|
253
|
+
)
|
|
254
|
+
form.deduccion_id.choices = [("", _("-- Sin deducción asociada --"))] + [(d.id, d.nombre) for d in deducciones]
|
|
255
|
+
|
|
256
|
+
if form.validate_on_submit():
|
|
257
|
+
prestamo.empleado_id = form.empleado_id.data
|
|
258
|
+
prestamo.tipo = form.tipo.data
|
|
259
|
+
prestamo.fecha_solicitud = form.fecha_solicitud.data
|
|
260
|
+
prestamo.monto_solicitado = form.monto_solicitado.data
|
|
261
|
+
prestamo.moneda_id = form.moneda_id.data
|
|
262
|
+
prestamo.cuotas_pactadas = form.cuotas_pactadas.data
|
|
263
|
+
prestamo.tasa_interes = form.tasa_interes.data or Decimal("0.0000")
|
|
264
|
+
prestamo.tipo_interes = form.tipo_interes.data
|
|
265
|
+
prestamo.metodo_amortizacion = form.metodo_amortizacion.data
|
|
266
|
+
prestamo.cuenta_debe = form.cuenta_debe.data
|
|
267
|
+
prestamo.cuenta_haber = form.cuenta_haber.data
|
|
268
|
+
prestamo.motivo = form.motivo.data
|
|
269
|
+
prestamo.modificado_por = current_user.usuario
|
|
270
|
+
|
|
271
|
+
# Set deduccion_id
|
|
272
|
+
if form.deduccion_id.data:
|
|
273
|
+
prestamo.deduccion_id = form.deduccion_id.data
|
|
274
|
+
else:
|
|
275
|
+
prestamo.deduccion_id = None
|
|
276
|
+
|
|
277
|
+
db.session.commit()
|
|
278
|
+
|
|
279
|
+
flash(_("Préstamo/Adelanto actualizado exitosamente."), "success")
|
|
280
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
281
|
+
|
|
282
|
+
return render_template("modules/prestamo/form.html", form=form, prestamo=prestamo)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@prestamo_bp.route("/<prestamo_id>/submit", methods=["POST"])
|
|
286
|
+
@require_write_access()
|
|
287
|
+
def submit(prestamo_id):
|
|
288
|
+
"""Submit a loan for approval (change from draft to pending)."""
|
|
289
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
290
|
+
if not prestamo:
|
|
291
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
292
|
+
return redirect(url_for("prestamo.index"))
|
|
293
|
+
|
|
294
|
+
if prestamo.estado != AdelantoEstado.BORRADOR:
|
|
295
|
+
flash(_("Solo los préstamos en borrador pueden ser enviados."), "warning")
|
|
296
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
297
|
+
|
|
298
|
+
prestamo.estado = AdelantoEstado.PENDIENTE
|
|
299
|
+
prestamo.modificado_por = current_user.usuario
|
|
300
|
+
db.session.commit()
|
|
301
|
+
|
|
302
|
+
flash(_("Préstamo enviado para aprobación."), "success")
|
|
303
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@prestamo_bp.route("/<prestamo_id>/approve", methods=["GET", "POST"])
|
|
307
|
+
@require_write_access()
|
|
308
|
+
def approve(prestamo_id):
|
|
309
|
+
"""Approve a loan and set it as active."""
|
|
310
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
311
|
+
if not prestamo:
|
|
312
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
313
|
+
return redirect(url_for("prestamo.index"))
|
|
314
|
+
|
|
315
|
+
if prestamo.estado not in [AdelantoEstado.PENDIENTE, AdelantoEstado.BORRADOR]:
|
|
316
|
+
flash(_("Este préstamo no puede ser aprobado en su estado actual."), "warning")
|
|
317
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
318
|
+
|
|
319
|
+
form = PrestamoApprovalForm()
|
|
320
|
+
|
|
321
|
+
if form.validate_on_submit():
|
|
322
|
+
if form.aprobar.data:
|
|
323
|
+
# Approve the loan
|
|
324
|
+
prestamo.monto_aprobado = form.monto_aprobado.data
|
|
325
|
+
prestamo.fecha_aprobacion = form.fecha_aprobacion.data
|
|
326
|
+
prestamo.fecha_desembolso = form.fecha_desembolso.data
|
|
327
|
+
prestamo.estado = AdelantoEstado.APROBADO
|
|
328
|
+
prestamo.aprobado_por = current_user.usuario
|
|
329
|
+
|
|
330
|
+
# Calculate installment amount based on amortization method
|
|
331
|
+
if prestamo.cuotas_pactadas and prestamo.cuotas_pactadas > 0:
|
|
332
|
+
from coati_payroll.interes_engine import calcular_cuota_frances
|
|
333
|
+
|
|
334
|
+
tasa_interes = prestamo.tasa_interes or Decimal("0.0000")
|
|
335
|
+
metodo = prestamo.metodo_amortizacion or "frances"
|
|
336
|
+
|
|
337
|
+
# For French method, calculate constant payment
|
|
338
|
+
# For German method, payment varies so we store the first payment
|
|
339
|
+
if metodo == "frances":
|
|
340
|
+
prestamo.monto_por_cuota = calcular_cuota_frances(
|
|
341
|
+
prestamo.monto_aprobado, tasa_interes, prestamo.cuotas_pactadas
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
# For German method, store average payment for reference
|
|
345
|
+
# Actual payment will be calculated per installment
|
|
346
|
+
prestamo.monto_por_cuota = prestamo.monto_aprobado / prestamo.cuotas_pactadas
|
|
347
|
+
else:
|
|
348
|
+
prestamo.monto_por_cuota = prestamo.monto_aprobado
|
|
349
|
+
|
|
350
|
+
# Set pending balance and initialize interest tracking
|
|
351
|
+
prestamo.saldo_pendiente = prestamo.monto_aprobado
|
|
352
|
+
prestamo.interes_acumulado = Decimal("0.00")
|
|
353
|
+
prestamo.fecha_ultimo_calculo_interes = prestamo.fecha_aprobacion or date.today()
|
|
354
|
+
prestamo.modificado_por = current_user.usuario
|
|
355
|
+
|
|
356
|
+
db.session.commit()
|
|
357
|
+
flash(_("Préstamo aprobado exitosamente."), "success")
|
|
358
|
+
|
|
359
|
+
elif form.rechazar.data:
|
|
360
|
+
# Reject the loan
|
|
361
|
+
prestamo.estado = AdelantoEstado.RECHAZADO
|
|
362
|
+
prestamo.rechazado_por = current_user.usuario
|
|
363
|
+
prestamo.motivo_rechazo = form.motivo_rechazo.data
|
|
364
|
+
prestamo.modificado_por = current_user.usuario
|
|
365
|
+
|
|
366
|
+
db.session.commit()
|
|
367
|
+
flash(_("Préstamo rechazado."), "info")
|
|
368
|
+
|
|
369
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
370
|
+
|
|
371
|
+
# Pre-populate form with loan data
|
|
372
|
+
if request.method == "GET":
|
|
373
|
+
form.monto_aprobado.data = prestamo.monto_solicitado
|
|
374
|
+
form.fecha_aprobacion.data = date.today()
|
|
375
|
+
|
|
376
|
+
return render_template("modules/prestamo/approve.html", form=form, prestamo=prestamo)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@prestamo_bp.route("/<prestamo_id>/cancel", methods=["POST"])
|
|
380
|
+
@require_write_access()
|
|
381
|
+
def cancel(prestamo_id):
|
|
382
|
+
"""Cancel a loan."""
|
|
383
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
384
|
+
if not prestamo:
|
|
385
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
386
|
+
return redirect(url_for("prestamo.index"))
|
|
387
|
+
|
|
388
|
+
if prestamo.estado in [AdelantoEstado.PAGADO, AdelantoEstado.CANCELADO]:
|
|
389
|
+
flash(_("Este préstamo ya está finalizado."), "warning")
|
|
390
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
391
|
+
|
|
392
|
+
prestamo.estado = AdelantoEstado.CANCELADO
|
|
393
|
+
prestamo.modificado_por = current_user.usuario
|
|
394
|
+
db.session.commit()
|
|
395
|
+
|
|
396
|
+
flash(_("Préstamo cancelado."), "info")
|
|
397
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@prestamo_bp.route("/<prestamo_id>/pago-extraordinario", methods=["GET", "POST"])
|
|
401
|
+
@require_write_access()
|
|
402
|
+
def pago_extraordinario(prestamo_id):
|
|
403
|
+
"""Register an extraordinary/manual payment on a loan."""
|
|
404
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
405
|
+
if not prestamo:
|
|
406
|
+
flash("Préstamo no encontrado.", "danger")
|
|
407
|
+
return redirect(url_for("prestamo.index"))
|
|
408
|
+
|
|
409
|
+
# Touch relationship to ensure it is loaded before rendering
|
|
410
|
+
prestamo.empleado
|
|
411
|
+
|
|
412
|
+
# Only allow payments on approved/active loans
|
|
413
|
+
if prestamo.estado not in [AdelantoEstado.APROBADO, AdelantoEstado.APLICADO]:
|
|
414
|
+
flash(
|
|
415
|
+
_("Solo se pueden registrar pagos en préstamos aprobados o aplicados."),
|
|
416
|
+
"warning",
|
|
417
|
+
)
|
|
418
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
419
|
+
|
|
420
|
+
if prestamo.saldo_pendiente <= 0:
|
|
421
|
+
flash(_("Este préstamo ya está totalmente pagado."), "info")
|
|
422
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
423
|
+
|
|
424
|
+
form = PagoExtraordinarioForm()
|
|
425
|
+
|
|
426
|
+
if form.validate_on_submit():
|
|
427
|
+
monto_abonado = form.monto_abonado.data
|
|
428
|
+
|
|
429
|
+
# Validate payment amount
|
|
430
|
+
if monto_abonado > prestamo.saldo_pendiente:
|
|
431
|
+
flash(
|
|
432
|
+
_("El monto del pago ({monto}) excede el saldo pendiente ({saldo}).").format(
|
|
433
|
+
monto=monto_abonado, saldo=prestamo.saldo_pendiente
|
|
434
|
+
),
|
|
435
|
+
"warning",
|
|
436
|
+
)
|
|
437
|
+
return render_template(
|
|
438
|
+
"modules/prestamo/pago_extraordinario.html",
|
|
439
|
+
form=form,
|
|
440
|
+
prestamo=prestamo,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Record the payment
|
|
444
|
+
abono = AdelantoAbono()
|
|
445
|
+
abono.adelanto_id = prestamo.id
|
|
446
|
+
abono.fecha_abono = form.fecha_abono.data
|
|
447
|
+
abono.monto_abonado = monto_abonado
|
|
448
|
+
abono.saldo_anterior = prestamo.saldo_pendiente
|
|
449
|
+
abono.saldo_posterior = prestamo.saldo_pendiente - monto_abonado
|
|
450
|
+
abono.tipo_abono = "manual"
|
|
451
|
+
abono.observaciones = form.observaciones.data or "Pago extraordinario"
|
|
452
|
+
# Audit trail information
|
|
453
|
+
abono.tipo_comprobante = form.tipo_comprobante.data
|
|
454
|
+
abono.numero_comprobante = form.numero_comprobante.data
|
|
455
|
+
abono.referencia_bancaria = form.referencia_bancaria.data
|
|
456
|
+
# Optional accounting entries
|
|
457
|
+
abono.cuenta_debe = form.cuenta_debe.data
|
|
458
|
+
abono.cuenta_haber = form.cuenta_haber.data
|
|
459
|
+
abono.creado_por = current_user.usuario
|
|
460
|
+
|
|
461
|
+
# Update loan balance
|
|
462
|
+
prestamo.saldo_pendiente = abono.saldo_posterior
|
|
463
|
+
prestamo.modificado_por = current_user.usuario
|
|
464
|
+
|
|
465
|
+
# Apply payment according to selected method
|
|
466
|
+
tipo_aplicacion = form.tipo_aplicacion.data
|
|
467
|
+
|
|
468
|
+
# Calculate remaining installments (those not yet paid)
|
|
469
|
+
total_abonado_previo = sum(a.monto_abonado for a in prestamo.abonos if a.tipo_abono in ["nomina", "manual"])
|
|
470
|
+
cuotas_pagadas = 0
|
|
471
|
+
if prestamo.monto_por_cuota and prestamo.monto_por_cuota > 0:
|
|
472
|
+
cuotas_pagadas = int(total_abonado_previo / prestamo.monto_por_cuota)
|
|
473
|
+
|
|
474
|
+
cuotas_pendientes = prestamo.cuotas_pactadas - cuotas_pagadas
|
|
475
|
+
|
|
476
|
+
if tipo_aplicacion == "reducir_cuotas":
|
|
477
|
+
# Option 1: Reduce number of installments, keep installment amount
|
|
478
|
+
if prestamo.monto_por_cuota and prestamo.monto_por_cuota > 0:
|
|
479
|
+
cuotas_a_eliminar = int(monto_abonado / prestamo.monto_por_cuota)
|
|
480
|
+
# Store original values for the observation
|
|
481
|
+
cuotas_originales = prestamo.cuotas_pactadas
|
|
482
|
+
# Adjust total installments
|
|
483
|
+
nueva_cuotas_pactadas = max(cuotas_pagadas, prestamo.cuotas_pactadas - cuotas_a_eliminar)
|
|
484
|
+
prestamo.cuotas_pactadas = nueva_cuotas_pactadas
|
|
485
|
+
|
|
486
|
+
abono.observaciones = (
|
|
487
|
+
f"{abono.observaciones or 'Pago extraordinario'} - "
|
|
488
|
+
f"Cuotas reducidas de {cuotas_originales} a {nueva_cuotas_pactadas}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
elif tipo_aplicacion == "reducir_monto":
|
|
492
|
+
# Option 2: Reduce installment amount, keep number of installments
|
|
493
|
+
if cuotas_pendientes > 0:
|
|
494
|
+
# Recalculate installment amount based on remaining balance
|
|
495
|
+
nueva_cuota = prestamo.saldo_pendiente / cuotas_pendientes
|
|
496
|
+
monto_original = prestamo.monto_por_cuota
|
|
497
|
+
prestamo.monto_por_cuota = nueva_cuota
|
|
498
|
+
|
|
499
|
+
abono.observaciones = (
|
|
500
|
+
f"{abono.observaciones or 'Pago extraordinario'} - "
|
|
501
|
+
f"Monto por cuota reducido de {monto_original:.2f} a {nueva_cuota:.2f}"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Check if loan is fully paid
|
|
505
|
+
if prestamo.saldo_pendiente <= Decimal("0.01"): # Allow for rounding
|
|
506
|
+
prestamo.saldo_pendiente = Decimal("0.00")
|
|
507
|
+
prestamo.estado = AdelantoEstado.PAGADO
|
|
508
|
+
|
|
509
|
+
db.session.add(abono)
|
|
510
|
+
db.session.commit()
|
|
511
|
+
|
|
512
|
+
flash(
|
|
513
|
+
_("Pago extraordinario registrado exitosamente."),
|
|
514
|
+
"success",
|
|
515
|
+
)
|
|
516
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
517
|
+
|
|
518
|
+
# Pre-populate form
|
|
519
|
+
if request.method == "GET":
|
|
520
|
+
form.fecha_abono.data = date.today()
|
|
521
|
+
# Default to reducing installment amount (usually more beneficial for employee)
|
|
522
|
+
form.tipo_aplicacion.data = "reducir_monto"
|
|
523
|
+
|
|
524
|
+
return render_template(
|
|
525
|
+
"modules/prestamo/pago_extraordinario.html",
|
|
526
|
+
form=form,
|
|
527
|
+
prestamo=prestamo,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@prestamo_bp.route("/<prestamo_id>/condonacion", methods=["GET", "POST"])
|
|
532
|
+
@require_write_access()
|
|
533
|
+
def condonacion(prestamo_id):
|
|
534
|
+
"""Record a loan forgiveness/write-off (condonación de deuda)."""
|
|
535
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
536
|
+
if not prestamo:
|
|
537
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
538
|
+
return redirect(url_for("prestamo.index"))
|
|
539
|
+
|
|
540
|
+
# Only allow forgiveness on approved/active loans
|
|
541
|
+
if prestamo.estado not in [AdelantoEstado.APROBADO, AdelantoEstado.APLICADO]:
|
|
542
|
+
flash(
|
|
543
|
+
_("Solo se pueden condonar préstamos aprobados o aplicados."),
|
|
544
|
+
"warning",
|
|
545
|
+
)
|
|
546
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
547
|
+
|
|
548
|
+
if prestamo.saldo_pendiente <= 0:
|
|
549
|
+
flash(_("Este préstamo ya está totalmente pagado."), "info")
|
|
550
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
551
|
+
|
|
552
|
+
form = CondonacionForm()
|
|
553
|
+
|
|
554
|
+
if form.validate_on_submit():
|
|
555
|
+
monto_condonado = form.monto_condonado.data
|
|
556
|
+
|
|
557
|
+
# Validate forgiveness amount
|
|
558
|
+
if monto_condonado > prestamo.saldo_pendiente:
|
|
559
|
+
flash(
|
|
560
|
+
_("El monto a condonar ({monto}) excede el saldo pendiente ({saldo}).").format(
|
|
561
|
+
monto=monto_condonado, saldo=prestamo.saldo_pendiente
|
|
562
|
+
),
|
|
563
|
+
"warning",
|
|
564
|
+
)
|
|
565
|
+
return render_template(
|
|
566
|
+
"modules/prestamo/condonacion.html",
|
|
567
|
+
form=form,
|
|
568
|
+
prestamo=prestamo,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Record the forgiveness as a special type of payment
|
|
572
|
+
abono = AdelantoAbono()
|
|
573
|
+
abono.adelanto_id = prestamo.id
|
|
574
|
+
abono.fecha_abono = form.fecha_condonacion.data
|
|
575
|
+
abono.monto_abonado = monto_condonado
|
|
576
|
+
abono.saldo_anterior = prestamo.saldo_pendiente
|
|
577
|
+
abono.saldo_posterior = prestamo.saldo_pendiente - monto_condonado
|
|
578
|
+
abono.tipo_abono = "condonacion"
|
|
579
|
+
|
|
580
|
+
# Store complete audit trail
|
|
581
|
+
abono.autorizado_por = form.autorizado_por.data
|
|
582
|
+
abono.documento_soporte = form.documento_soporte.data
|
|
583
|
+
abono.referencia_documento = form.referencia_documento.data
|
|
584
|
+
abono.justificacion = form.justificacion.data
|
|
585
|
+
# Optional accounting entries
|
|
586
|
+
abono.cuenta_debe = form.cuenta_debe.data
|
|
587
|
+
abono.cuenta_haber = form.cuenta_haber.data
|
|
588
|
+
|
|
589
|
+
# Build observation summary
|
|
590
|
+
porcentaje = ""
|
|
591
|
+
if form.porcentaje_condonado.data:
|
|
592
|
+
porcentaje = f" ({form.porcentaje_condonado.data}%)"
|
|
593
|
+
|
|
594
|
+
abono.observaciones = (
|
|
595
|
+
f"CONDONACIÓN DE DEUDA{porcentaje} - "
|
|
596
|
+
f"Autorizado por: {form.autorizado_por.data}. "
|
|
597
|
+
f"Documento: {form.documento_soporte.data} - {form.referencia_documento.data}"
|
|
598
|
+
)
|
|
599
|
+
abono.creado_por = current_user.usuario
|
|
600
|
+
|
|
601
|
+
# Update loan balance
|
|
602
|
+
prestamo.saldo_pendiente = abono.saldo_posterior
|
|
603
|
+
prestamo.modificado_por = current_user.usuario
|
|
604
|
+
|
|
605
|
+
# If loan is fully forgiven/paid, mark as paid
|
|
606
|
+
if prestamo.saldo_pendiente <= Decimal("0.01"): # Allow for rounding
|
|
607
|
+
prestamo.saldo_pendiente = Decimal("0.00")
|
|
608
|
+
prestamo.estado = AdelantoEstado.PAGADO
|
|
609
|
+
|
|
610
|
+
db.session.add(abono)
|
|
611
|
+
db.session.commit()
|
|
612
|
+
|
|
613
|
+
flash(
|
|
614
|
+
_("Condonación de deuda registrada exitosamente. Monto condonado: {monto}").format(monto=monto_condonado),
|
|
615
|
+
"success",
|
|
616
|
+
)
|
|
617
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
618
|
+
|
|
619
|
+
# Pre-populate form
|
|
620
|
+
if request.method == "GET":
|
|
621
|
+
form.fecha_condonacion.data = date.today()
|
|
622
|
+
form.monto_condonado.data = prestamo.saldo_pendiente
|
|
623
|
+
|
|
624
|
+
return render_template(
|
|
625
|
+
"modules/prestamo/condonacion.html",
|
|
626
|
+
form=form,
|
|
627
|
+
prestamo=prestamo,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@prestamo_bp.route("/<prestamo_id>/tabla-pago/excel")
|
|
632
|
+
@require_read_access()
|
|
633
|
+
def export_excel(prestamo_id):
|
|
634
|
+
"""Export payment schedule to Excel."""
|
|
635
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
636
|
+
if not prestamo:
|
|
637
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
638
|
+
return redirect(url_for("prestamo.index"))
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
from openpyxl import Workbook
|
|
642
|
+
from openpyxl.styles import Font, PatternFill
|
|
643
|
+
except ImportError:
|
|
644
|
+
flash(
|
|
645
|
+
_("Excel export no disponible. Instale openpyxl."),
|
|
646
|
+
"warning",
|
|
647
|
+
)
|
|
648
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
649
|
+
|
|
650
|
+
# Generate payment schedule
|
|
651
|
+
tabla_pago = generar_tabla_pago(prestamo)
|
|
652
|
+
|
|
653
|
+
# Create workbook
|
|
654
|
+
wb = Workbook()
|
|
655
|
+
ws = wb.active
|
|
656
|
+
ws.title = "Tabla de Pagos"
|
|
657
|
+
|
|
658
|
+
# Header
|
|
659
|
+
ws["A1"] = "TABLA DE PAGOS - PRÉSTAMO/ADELANTO"
|
|
660
|
+
ws["A1"].font = Font(bold=True, size=14)
|
|
661
|
+
ws.merge_cells("A1:E1")
|
|
662
|
+
|
|
663
|
+
# Loan details
|
|
664
|
+
row = 3
|
|
665
|
+
ws[f"A{row}"] = "Empleado:"
|
|
666
|
+
ws[f"B{row}"] = f"{prestamo.empleado.primer_nombre} {prestamo.empleado.primer_apellido}"
|
|
667
|
+
row += 1
|
|
668
|
+
ws[f"A{row}"] = "Tipo:"
|
|
669
|
+
ws[f"B{row}"] = prestamo.tipo
|
|
670
|
+
row += 1
|
|
671
|
+
ws[f"A{row}"] = "Monto:"
|
|
672
|
+
ws[f"B{row}"] = float(prestamo.monto_aprobado or prestamo.monto_solicitado)
|
|
673
|
+
row += 1
|
|
674
|
+
ws[f"A{row}"] = "Cuotas:"
|
|
675
|
+
ws[f"B{row}"] = prestamo.cuotas_pactadas
|
|
676
|
+
row += 2
|
|
677
|
+
|
|
678
|
+
# Table header
|
|
679
|
+
headers = ["#", "Fecha Estimada", "Cuota", "Interés", "Capital", "Saldo"]
|
|
680
|
+
for col, header in enumerate(headers, start=1):
|
|
681
|
+
cell = ws.cell(row=row, column=col, value=header)
|
|
682
|
+
cell.font = Font(bold=True)
|
|
683
|
+
cell.fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
|
684
|
+
cell.font = Font(bold=True, color="FFFFFF")
|
|
685
|
+
|
|
686
|
+
# Table data
|
|
687
|
+
for item in tabla_pago:
|
|
688
|
+
row += 1
|
|
689
|
+
ws.cell(row=row, column=1, value=item["numero"])
|
|
690
|
+
ws.cell(row=row, column=2, value=item["fecha_estimada"])
|
|
691
|
+
ws.cell(row=row, column=3, value=float(item["cuota"]))
|
|
692
|
+
ws.cell(row=row, column=4, value=float(item["interes"]))
|
|
693
|
+
ws.cell(row=row, column=5, value=float(item["capital"]))
|
|
694
|
+
ws.cell(row=row, column=6, value=float(item["saldo"]))
|
|
695
|
+
|
|
696
|
+
# Save to BytesIO
|
|
697
|
+
output = BytesIO()
|
|
698
|
+
wb.save(output)
|
|
699
|
+
output.seek(0)
|
|
700
|
+
|
|
701
|
+
# Use first 8 chars of ULID (26 chars total) for short filename
|
|
702
|
+
filename = f"tabla_pago_{prestamo.empleado.codigo_empleado}_{prestamo.id[:8]}.xlsx"
|
|
703
|
+
|
|
704
|
+
return send_file(
|
|
705
|
+
output,
|
|
706
|
+
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
707
|
+
as_attachment=True,
|
|
708
|
+
download_name=filename,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@prestamo_bp.route("/<prestamo_id>/tabla-pago/pdf")
|
|
713
|
+
@require_read_access()
|
|
714
|
+
def export_pdf(prestamo_id):
|
|
715
|
+
"""Export payment schedule to PDF."""
|
|
716
|
+
prestamo = db.session.get(Adelanto, prestamo_id)
|
|
717
|
+
if not prestamo:
|
|
718
|
+
flash(_("Préstamo no encontrado."), "danger")
|
|
719
|
+
return redirect(url_for("prestamo.index"))
|
|
720
|
+
|
|
721
|
+
# Generate payment schedule
|
|
722
|
+
tabla_pago = generar_tabla_pago(prestamo)
|
|
723
|
+
|
|
724
|
+
# Render HTML template for PDF
|
|
725
|
+
html = render_template(
|
|
726
|
+
"modules/prestamo/tabla_pago_pdf.html",
|
|
727
|
+
prestamo=prestamo,
|
|
728
|
+
tabla_pago=tabla_pago,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
from flask_weasyprint import HTML, render_pdf
|
|
733
|
+
|
|
734
|
+
pdf = render_pdf(HTML(string=html))
|
|
735
|
+
# Use first 8 chars of ULID (26 chars total) for short filename
|
|
736
|
+
filename = f"tabla_pago_{prestamo.empleado.codigo_empleado}_{prestamo.id[:8]}.pdf"
|
|
737
|
+
|
|
738
|
+
return Response(
|
|
739
|
+
pdf,
|
|
740
|
+
mimetype="application/pdf",
|
|
741
|
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
742
|
+
)
|
|
743
|
+
except ImportError:
|
|
744
|
+
flash(
|
|
745
|
+
_("PDF export no disponible. Instale WeasyPrint."),
|
|
746
|
+
"warning",
|
|
747
|
+
)
|
|
748
|
+
return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def generar_tabla_pago(prestamo: Adelanto) -> list[dict]:
|
|
752
|
+
"""Generate payment schedule for a loan.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
prestamo: Loan object
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
List of payment schedule items with fields:
|
|
759
|
+
- numero: Payment number
|
|
760
|
+
- fecha_estimada: Estimated payment date
|
|
761
|
+
- cuota: Total payment amount
|
|
762
|
+
- interes: Interest portion
|
|
763
|
+
- capital: Principal portion
|
|
764
|
+
- saldo: Remaining balance
|
|
765
|
+
"""
|
|
766
|
+
if not prestamo.cuotas_pactadas or prestamo.cuotas_pactadas <= 0:
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
monto_base = prestamo.monto_aprobado or prestamo.monto_solicitado
|
|
770
|
+
if not monto_base or monto_base <= 0:
|
|
771
|
+
return []
|
|
772
|
+
|
|
773
|
+
# Determine start date
|
|
774
|
+
fecha_inicio = prestamo.fecha_aprobacion or prestamo.fecha_solicitud or date.today()
|
|
775
|
+
|
|
776
|
+
# Import interest engine
|
|
777
|
+
from coati_payroll.interes_engine import generar_tabla_amortizacion
|
|
778
|
+
|
|
779
|
+
# Get interest rate and type
|
|
780
|
+
tasa_interes = prestamo.tasa_interes or Decimal("0.0000")
|
|
781
|
+
tipo_interes = prestamo.tipo_interes or "ninguno"
|
|
782
|
+
metodo_amortizacion = prestamo.metodo_amortizacion or "frances"
|
|
783
|
+
|
|
784
|
+
# Generate amortization schedule using the interest engine
|
|
785
|
+
cuotas = generar_tabla_amortizacion(
|
|
786
|
+
principal=monto_base,
|
|
787
|
+
tasa_anual=tasa_interes,
|
|
788
|
+
num_cuotas=prestamo.cuotas_pactadas,
|
|
789
|
+
fecha_inicio=fecha_inicio,
|
|
790
|
+
metodo=metodo_amortizacion,
|
|
791
|
+
tipo_interes=tipo_interes,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
# Convert to dict format for template
|
|
795
|
+
tabla = []
|
|
796
|
+
for cuota in cuotas:
|
|
797
|
+
tabla.append(
|
|
798
|
+
{
|
|
799
|
+
"numero": cuota.numero,
|
|
800
|
+
"fecha_estimada": cuota.fecha_estimada,
|
|
801
|
+
"cuota": cuota.cuota_total,
|
|
802
|
+
"interes": cuota.interes,
|
|
803
|
+
"capital": cuota.capital,
|
|
804
|
+
"saldo": cuota.saldo,
|
|
805
|
+
}
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
return tabla
|