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,1045 @@
|
|
|
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
|
+
"""Vacation module views.
|
|
15
|
+
|
|
16
|
+
This module provides views for managing vacation policies, accounts, and leave requests.
|
|
17
|
+
Implements a robust, auditable, and country-agnostic vacation management system.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import date
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
|
|
25
|
+
from flask import Blueprint, flash, redirect, render_template, request, url_for, jsonify
|
|
26
|
+
from flask_login import current_user, login_required
|
|
27
|
+
from sqlalchemy import func
|
|
28
|
+
from sqlalchemy.orm import selectinload
|
|
29
|
+
|
|
30
|
+
from coati_payroll.enums import TipoUsuario, VacationLedgerType
|
|
31
|
+
from coati_payroll.i18n import _
|
|
32
|
+
from coati_payroll.model import (
|
|
33
|
+
db,
|
|
34
|
+
VacationPolicy,
|
|
35
|
+
VacationAccount,
|
|
36
|
+
VacationLedger,
|
|
37
|
+
VacationNovelty,
|
|
38
|
+
Empleado,
|
|
39
|
+
Empresa,
|
|
40
|
+
)
|
|
41
|
+
from coati_payroll.rbac import require_role, require_read_access, require_write_access
|
|
42
|
+
|
|
43
|
+
vacation_bp = Blueprint("vacation", __name__, url_prefix="/vacation")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# Vacation Policy Management
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@vacation_bp.route("/policies")
|
|
52
|
+
@require_read_access()
|
|
53
|
+
def policy_index():
|
|
54
|
+
"""List all vacation policies."""
|
|
55
|
+
page = request.args.get("page", 1, type=int)
|
|
56
|
+
per_page = 20
|
|
57
|
+
|
|
58
|
+
query = db.select(VacationPolicy).order_by(VacationPolicy.nombre)
|
|
59
|
+
pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
|
|
60
|
+
policies = pagination.items
|
|
61
|
+
|
|
62
|
+
return render_template(
|
|
63
|
+
"modules/vacation/policy_index.html",
|
|
64
|
+
policies=policies,
|
|
65
|
+
pagination=pagination,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@vacation_bp.route("/policies/new", methods=["GET", "POST"])
|
|
70
|
+
@require_role(TipoUsuario.ADMIN)
|
|
71
|
+
def policy_new():
|
|
72
|
+
"""Create a new vacation policy. Only administrators can create policies."""
|
|
73
|
+
from coati_payroll.forms import VacationPolicyForm
|
|
74
|
+
from coati_payroll.model import Planilla
|
|
75
|
+
|
|
76
|
+
form = VacationPolicyForm()
|
|
77
|
+
|
|
78
|
+
# Populate planilla choices
|
|
79
|
+
planillas = (
|
|
80
|
+
db.session.execute(
|
|
81
|
+
db.select(Planilla)
|
|
82
|
+
.options(selectinload(Planilla.empresa))
|
|
83
|
+
.filter(Planilla.activo.is_(True))
|
|
84
|
+
.order_by(Planilla.nombre)
|
|
85
|
+
)
|
|
86
|
+
.scalars()
|
|
87
|
+
.all()
|
|
88
|
+
)
|
|
89
|
+
form.planilla_id.choices = [("", _("-- Seleccionar Planilla --"))] + [
|
|
90
|
+
(p.id, f"{p.nombre} ({p.empresa.razon_social if p.empresa else 'N/A'})") for p in planillas
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# Populate empresa choices
|
|
94
|
+
empresas = (
|
|
95
|
+
db.session.execute(db.select(Empresa).filter(Empresa.activo.is_(True)).order_by(Empresa.razon_social))
|
|
96
|
+
.scalars()
|
|
97
|
+
.all()
|
|
98
|
+
)
|
|
99
|
+
form.empresa_id.choices = [("", _("-- Seleccionar Empresa --"))] + [(e.id, e.razon_social) for e in empresas]
|
|
100
|
+
|
|
101
|
+
if form.validate_on_submit():
|
|
102
|
+
policy = VacationPolicy()
|
|
103
|
+
form.populate_obj(policy)
|
|
104
|
+
policy.creado_por = current_user.usuario
|
|
105
|
+
|
|
106
|
+
db.session.add(policy)
|
|
107
|
+
try:
|
|
108
|
+
db.session.commit()
|
|
109
|
+
flash(_("Política de vacaciones creada exitosamente."), "success")
|
|
110
|
+
return redirect(url_for("vacation.policy_index"))
|
|
111
|
+
except Exception as e:
|
|
112
|
+
db.session.rollback()
|
|
113
|
+
flash(_("Error al crear la política: {}").format(str(e)), "danger")
|
|
114
|
+
|
|
115
|
+
return render_template(
|
|
116
|
+
"modules/vacation/policy_form.html",
|
|
117
|
+
form=form,
|
|
118
|
+
titulo=_("Nueva Política de Vacaciones"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@vacation_bp.route("/policies/<string:policy_id>/edit", methods=["GET", "POST"])
|
|
123
|
+
@require_role(TipoUsuario.ADMIN)
|
|
124
|
+
def policy_edit(policy_id):
|
|
125
|
+
"""Edit an existing vacation policy. Only administrators can edit policies."""
|
|
126
|
+
from coati_payroll.forms import VacationPolicyForm
|
|
127
|
+
from coati_payroll.model import Planilla
|
|
128
|
+
|
|
129
|
+
policy = db.session.get(VacationPolicy, policy_id)
|
|
130
|
+
if not policy:
|
|
131
|
+
flash(_("Política no encontrada."), "warning")
|
|
132
|
+
return redirect(url_for("vacation.policy_index"))
|
|
133
|
+
|
|
134
|
+
form = VacationPolicyForm(obj=policy)
|
|
135
|
+
|
|
136
|
+
# Populate planilla choices
|
|
137
|
+
planillas = (
|
|
138
|
+
db.session.execute(
|
|
139
|
+
db.select(Planilla)
|
|
140
|
+
.options(selectinload(Planilla.empresa))
|
|
141
|
+
.filter(Planilla.activo.is_(True))
|
|
142
|
+
.order_by(Planilla.nombre)
|
|
143
|
+
)
|
|
144
|
+
.scalars()
|
|
145
|
+
.all()
|
|
146
|
+
)
|
|
147
|
+
form.planilla_id.choices = [("", _("-- Seleccionar Planilla --"))] + [
|
|
148
|
+
(p.id, f"{p.nombre} ({p.empresa.razon_social if p.empresa else 'N/A'})") for p in planillas
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
# Populate empresa choices
|
|
152
|
+
empresas = (
|
|
153
|
+
db.session.execute(db.select(Empresa).filter(Empresa.activo.is_(True)).order_by(Empresa.razon_social))
|
|
154
|
+
.scalars()
|
|
155
|
+
.all()
|
|
156
|
+
)
|
|
157
|
+
form.empresa_id.choices = [("", _("-- Seleccionar Empresa --"))] + [(e.id, e.razon_social) for e in empresas]
|
|
158
|
+
|
|
159
|
+
if form.validate_on_submit():
|
|
160
|
+
form.populate_obj(policy)
|
|
161
|
+
policy.modificado_por = current_user.usuario
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
db.session.commit()
|
|
165
|
+
flash(_("Política actualizada exitosamente."), "success")
|
|
166
|
+
return redirect(url_for("vacation.policy_index"))
|
|
167
|
+
except Exception as e:
|
|
168
|
+
db.session.rollback()
|
|
169
|
+
flash(_("Error al actualizar la política: {}").format(str(e)), "danger")
|
|
170
|
+
|
|
171
|
+
return render_template(
|
|
172
|
+
"modules/vacation/policy_form.html",
|
|
173
|
+
form=form,
|
|
174
|
+
policy=policy,
|
|
175
|
+
titulo=_("Editar Política de Vacaciones"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@vacation_bp.route("/policies/<string:policy_id>")
|
|
180
|
+
@require_read_access()
|
|
181
|
+
def policy_detail(policy_id):
|
|
182
|
+
"""View vacation policy details."""
|
|
183
|
+
policy = db.session.get(VacationPolicy, policy_id)
|
|
184
|
+
if not policy:
|
|
185
|
+
flash(_("Política no encontrada."), "warning")
|
|
186
|
+
return redirect(url_for("vacation.policy_index"))
|
|
187
|
+
|
|
188
|
+
# Get statistics
|
|
189
|
+
total_accounts = (
|
|
190
|
+
db.session.execute(
|
|
191
|
+
db.select(func.count(VacationAccount.id)).filter(VacationAccount.policy_id == policy_id)
|
|
192
|
+
).scalar()
|
|
193
|
+
or 0
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return render_template(
|
|
197
|
+
"modules/vacation/policy_detail.html",
|
|
198
|
+
policy=policy,
|
|
199
|
+
total_accounts=total_accounts,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ============================================================================
|
|
204
|
+
# Vacation Account Management
|
|
205
|
+
# ============================================================================
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@vacation_bp.route("/accounts")
|
|
209
|
+
@require_read_access()
|
|
210
|
+
def account_index():
|
|
211
|
+
"""List all vacation accounts."""
|
|
212
|
+
page = request.args.get("page", 1, type=int)
|
|
213
|
+
per_page = 20
|
|
214
|
+
|
|
215
|
+
# Join with Empleado to get employee details
|
|
216
|
+
query = (
|
|
217
|
+
db.select(VacationAccount)
|
|
218
|
+
.join(VacationAccount.empleado)
|
|
219
|
+
.order_by(Empleado.primer_apellido, Empleado.primer_nombre)
|
|
220
|
+
)
|
|
221
|
+
pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
|
|
222
|
+
accounts = pagination.items
|
|
223
|
+
|
|
224
|
+
return render_template(
|
|
225
|
+
"modules/vacation/account_index.html",
|
|
226
|
+
accounts=accounts,
|
|
227
|
+
pagination=pagination,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@vacation_bp.route("/accounts/<string:account_id>")
|
|
232
|
+
@require_read_access()
|
|
233
|
+
def account_detail(account_id):
|
|
234
|
+
"""View vacation account details and history."""
|
|
235
|
+
account = db.session.get(VacationAccount, account_id)
|
|
236
|
+
if not account:
|
|
237
|
+
flash(_("Cuenta no encontrada."), "warning")
|
|
238
|
+
return redirect(url_for("vacation.account_index"))
|
|
239
|
+
|
|
240
|
+
# Get ledger history
|
|
241
|
+
ledger_entries = (
|
|
242
|
+
db.session.execute(
|
|
243
|
+
db.select(VacationLedger)
|
|
244
|
+
.filter(VacationLedger.account_id == account_id)
|
|
245
|
+
.order_by(VacationLedger.fecha.desc())
|
|
246
|
+
.limit(50)
|
|
247
|
+
)
|
|
248
|
+
.scalars()
|
|
249
|
+
.all()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Get pending leave requests
|
|
253
|
+
pending_requests = (
|
|
254
|
+
db.session.execute(
|
|
255
|
+
db.select(VacationNovelty)
|
|
256
|
+
.filter(VacationNovelty.account_id == account_id, VacationNovelty.estado == "pendiente")
|
|
257
|
+
.order_by(VacationNovelty.start_date)
|
|
258
|
+
)
|
|
259
|
+
.scalars()
|
|
260
|
+
.all()
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return render_template(
|
|
264
|
+
"modules/vacation/account_detail.html",
|
|
265
|
+
account=account,
|
|
266
|
+
ledger_entries=ledger_entries,
|
|
267
|
+
pending_requests=pending_requests,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@vacation_bp.route("/accounts/new", methods=["GET", "POST"])
|
|
272
|
+
@require_role(TipoUsuario.ADMIN)
|
|
273
|
+
def account_new():
|
|
274
|
+
"""Create a new vacation account for an employee."""
|
|
275
|
+
from coati_payroll.forms import VacationAccountForm
|
|
276
|
+
|
|
277
|
+
form = VacationAccountForm()
|
|
278
|
+
|
|
279
|
+
if form.validate_on_submit():
|
|
280
|
+
account = VacationAccount()
|
|
281
|
+
form.populate_obj(account)
|
|
282
|
+
account.creado_por = current_user.usuario
|
|
283
|
+
|
|
284
|
+
db.session.add(account)
|
|
285
|
+
try:
|
|
286
|
+
db.session.commit()
|
|
287
|
+
flash(_("Cuenta de vacaciones creada exitosamente."), "success")
|
|
288
|
+
return redirect(url_for("vacation.account_detail", account_id=account.id))
|
|
289
|
+
except Exception as e:
|
|
290
|
+
db.session.rollback()
|
|
291
|
+
flash(_("Error al crear la cuenta: {}").format(str(e)), "danger")
|
|
292
|
+
|
|
293
|
+
return render_template(
|
|
294
|
+
"modules/vacation/account_form.html",
|
|
295
|
+
form=form,
|
|
296
|
+
titulo=_("Nueva Cuenta de Vacaciones"),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ============================================================================
|
|
301
|
+
# Vacation Leave Request Management
|
|
302
|
+
# ============================================================================
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@vacation_bp.route("/leave-requests")
|
|
306
|
+
@require_read_access()
|
|
307
|
+
def leave_request_index():
|
|
308
|
+
"""List vacation leave requests."""
|
|
309
|
+
page = request.args.get("page", 1, type=int)
|
|
310
|
+
per_page = 20
|
|
311
|
+
estado = request.args.get("estado", None)
|
|
312
|
+
|
|
313
|
+
query = db.select(VacationNovelty).join(VacationNovelty.empleado)
|
|
314
|
+
|
|
315
|
+
if estado:
|
|
316
|
+
query = query.filter(VacationNovelty.estado == estado)
|
|
317
|
+
|
|
318
|
+
query = query.order_by(VacationNovelty.start_date.desc())
|
|
319
|
+
pagination = db.paginate(query, page=page, per_page=per_page, error_out=False)
|
|
320
|
+
leave_requests = pagination.items
|
|
321
|
+
|
|
322
|
+
return render_template(
|
|
323
|
+
"modules/vacation/leave_request_index.html",
|
|
324
|
+
leave_requests=leave_requests,
|
|
325
|
+
pagination=pagination,
|
|
326
|
+
estado=estado,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@vacation_bp.route("/leave-requests/new", methods=["GET", "POST"])
|
|
331
|
+
@require_write_access()
|
|
332
|
+
def leave_request_new():
|
|
333
|
+
"""Create a new vacation leave request."""
|
|
334
|
+
from coati_payroll.forms import VacationLeaveRequestForm
|
|
335
|
+
|
|
336
|
+
form = VacationLeaveRequestForm()
|
|
337
|
+
|
|
338
|
+
if form.validate_on_submit():
|
|
339
|
+
# Validate that employee has a vacation account
|
|
340
|
+
account = db.session.execute(
|
|
341
|
+
db.select(VacationAccount).filter(
|
|
342
|
+
VacationAccount.empleado_id == form.empleado_id.data, VacationAccount.activo.is_(True)
|
|
343
|
+
)
|
|
344
|
+
).scalar_one_or_none()
|
|
345
|
+
|
|
346
|
+
if not account:
|
|
347
|
+
flash(_("El empleado no tiene una cuenta de vacaciones activa."), "danger")
|
|
348
|
+
return render_template(
|
|
349
|
+
"modules/vacation/leave_request_form.html",
|
|
350
|
+
form=form,
|
|
351
|
+
titulo=_("Nueva Solicitud de Vacaciones"),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Check balance
|
|
355
|
+
if account.current_balance < form.units.data:
|
|
356
|
+
if not account.policy.allow_negative:
|
|
357
|
+
flash(_("Saldo insuficiente para la solicitud."), "danger")
|
|
358
|
+
return render_template(
|
|
359
|
+
"modules/vacation/leave_request_form.html",
|
|
360
|
+
form=form,
|
|
361
|
+
titulo=_("Nueva Solicitud de Vacaciones"),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Create leave request
|
|
365
|
+
leave_request = VacationNovelty()
|
|
366
|
+
form.populate_obj(leave_request)
|
|
367
|
+
leave_request.account_id = account.id
|
|
368
|
+
leave_request.creado_por = current_user.usuario
|
|
369
|
+
|
|
370
|
+
db.session.add(leave_request)
|
|
371
|
+
try:
|
|
372
|
+
db.session.commit()
|
|
373
|
+
flash(_("Solicitud de vacaciones creada exitosamente."), "success")
|
|
374
|
+
return redirect(url_for("vacation.leave_request_detail", request_id=leave_request.id))
|
|
375
|
+
except Exception as e:
|
|
376
|
+
db.session.rollback()
|
|
377
|
+
flash(_("Error al crear la solicitud: {}").format(str(e)), "danger")
|
|
378
|
+
|
|
379
|
+
return render_template(
|
|
380
|
+
"modules/vacation/leave_request_form.html",
|
|
381
|
+
form=form,
|
|
382
|
+
titulo=_("Nueva Solicitud de Vacaciones"),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@vacation_bp.route("/leave-requests/<string:request_id>")
|
|
387
|
+
@require_read_access()
|
|
388
|
+
def leave_request_detail(request_id):
|
|
389
|
+
"""View vacation leave request details."""
|
|
390
|
+
leave_request = db.session.get(VacationNovelty, request_id)
|
|
391
|
+
if not leave_request:
|
|
392
|
+
flash(_("Solicitud no encontrada."), "warning")
|
|
393
|
+
return redirect(url_for("vacation.leave_request_index"))
|
|
394
|
+
|
|
395
|
+
return render_template(
|
|
396
|
+
"modules/vacation/leave_request_detail.html",
|
|
397
|
+
leave_request=leave_request,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@vacation_bp.route("/leave-requests/<string:request_id>/approve", methods=["POST"])
|
|
402
|
+
@require_role(TipoUsuario.ADMIN)
|
|
403
|
+
def leave_request_approve(request_id):
|
|
404
|
+
"""Approve a vacation leave request and create ledger entry."""
|
|
405
|
+
leave_request = db.session.get(VacationNovelty, request_id)
|
|
406
|
+
if not leave_request:
|
|
407
|
+
flash(_("Solicitud no encontrada."), "warning")
|
|
408
|
+
return redirect(url_for("vacation.leave_request_index"))
|
|
409
|
+
|
|
410
|
+
if leave_request.estado != "pendiente":
|
|
411
|
+
flash(_("Solo se pueden aprobar solicitudes pendientes."), "warning")
|
|
412
|
+
return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
|
|
413
|
+
|
|
414
|
+
# Update request status
|
|
415
|
+
leave_request.estado = "aprobado"
|
|
416
|
+
leave_request.fecha_aprobacion = date.today()
|
|
417
|
+
leave_request.aprobado_por = current_user.usuario
|
|
418
|
+
leave_request.modificado_por = current_user.usuario
|
|
419
|
+
|
|
420
|
+
# Create ledger entry for usage
|
|
421
|
+
ledger_entry = VacationLedger()
|
|
422
|
+
ledger_entry.account_id = leave_request.account_id
|
|
423
|
+
ledger_entry.empleado_id = leave_request.empleado_id
|
|
424
|
+
ledger_entry.fecha = date.today()
|
|
425
|
+
ledger_entry.entry_type = VacationLedgerType.USAGE
|
|
426
|
+
ledger_entry.quantity = -abs(leave_request.units) # Negative for usage
|
|
427
|
+
ledger_entry.source = "novelty"
|
|
428
|
+
ledger_entry.reference_id = leave_request.id
|
|
429
|
+
ledger_entry.reference_type = "vacation_novelty"
|
|
430
|
+
ledger_entry.observaciones = f"Vacaciones del {leave_request.start_date} al {leave_request.end_date}"
|
|
431
|
+
ledger_entry.creado_por = current_user.usuario
|
|
432
|
+
|
|
433
|
+
# Update account balance
|
|
434
|
+
account = leave_request.account
|
|
435
|
+
account.current_balance = account.current_balance - abs(leave_request.units)
|
|
436
|
+
ledger_entry.balance_after = account.current_balance
|
|
437
|
+
account.modificado_por = current_user.usuario
|
|
438
|
+
|
|
439
|
+
db.session.add(ledger_entry)
|
|
440
|
+
db.session.flush()
|
|
441
|
+
|
|
442
|
+
# Link ledger entry to request (after flush so ID is available)
|
|
443
|
+
leave_request.ledger_entry_id = ledger_entry.id
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
db.session.commit()
|
|
447
|
+
flash(_("Solicitud aprobada exitosamente."), "success")
|
|
448
|
+
except Exception as e:
|
|
449
|
+
db.session.rollback()
|
|
450
|
+
flash(_("Error al aprobar la solicitud: {}").format(str(e)), "danger")
|
|
451
|
+
|
|
452
|
+
return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@vacation_bp.route("/leave-requests/<string:request_id>/reject", methods=["POST"])
|
|
456
|
+
@require_role(TipoUsuario.ADMIN)
|
|
457
|
+
def leave_request_reject(request_id):
|
|
458
|
+
"""Reject a vacation leave request."""
|
|
459
|
+
leave_request = db.session.get(VacationNovelty, request_id)
|
|
460
|
+
if not leave_request:
|
|
461
|
+
flash(_("Solicitud no encontrada."), "warning")
|
|
462
|
+
return redirect(url_for("vacation.leave_request_index"))
|
|
463
|
+
|
|
464
|
+
if leave_request.estado != "pendiente":
|
|
465
|
+
flash(_("Solo se pueden rechazar solicitudes pendientes."), "warning")
|
|
466
|
+
return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
|
|
467
|
+
|
|
468
|
+
# Get rejection reason from form
|
|
469
|
+
motivo_rechazo = request.form.get("motivo_rechazo", "")
|
|
470
|
+
|
|
471
|
+
# Update request status
|
|
472
|
+
leave_request.estado = "rechazado"
|
|
473
|
+
leave_request.motivo_rechazo = motivo_rechazo
|
|
474
|
+
leave_request.modificado_por = current_user.usuario
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
db.session.commit()
|
|
478
|
+
flash(_("Solicitud rechazada."), "info")
|
|
479
|
+
except Exception as e:
|
|
480
|
+
db.session.rollback()
|
|
481
|
+
flash(_("Error al rechazar la solicitud: {}").format(str(e)), "danger")
|
|
482
|
+
|
|
483
|
+
return redirect(url_for("vacation.leave_request_detail", request_id=request_id))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ============================================================================
|
|
487
|
+
# Register Vacation Taken (Direct Registration with Novelty Creation)
|
|
488
|
+
# ============================================================================
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@vacation_bp.route("/register-taken", methods=["GET", "POST"])
|
|
492
|
+
@require_write_access()
|
|
493
|
+
def register_vacation_taken():
|
|
494
|
+
"""Register vacation days taken by an employee (creates vacation record + novelty).
|
|
495
|
+
|
|
496
|
+
This is an alternative method to register novelties from the vacation module.
|
|
497
|
+
The novelty is created using the existing infrastructure (NominaNovedad) and MUST
|
|
498
|
+
be associated with either a Percepcion or Deduccion for payroll calculations.
|
|
499
|
+
|
|
500
|
+
Workflow:
|
|
501
|
+
1. Creates a VacationNovelty record (vacation tracking)
|
|
502
|
+
2. Creates a linked NominaNovedad record (payroll integration)
|
|
503
|
+
3. The NominaNovedad is associated with the selected Percepcion/Deduccion
|
|
504
|
+
4. Marks vacation as approved and deducts from balance
|
|
505
|
+
5. When payroll is calculated, the novelty will be processed normally
|
|
506
|
+
"""
|
|
507
|
+
from coati_payroll.forms import VacationTakenForm
|
|
508
|
+
from coati_payroll.model import NominaNovedad, Percepcion, Deduccion
|
|
509
|
+
|
|
510
|
+
form = VacationTakenForm()
|
|
511
|
+
|
|
512
|
+
# Populate employee choices
|
|
513
|
+
empleados = (
|
|
514
|
+
db.session.execute(
|
|
515
|
+
db.select(Empleado)
|
|
516
|
+
.filter(Empleado.activo.is_(True))
|
|
517
|
+
.order_by(Empleado.primer_apellido, Empleado.primer_nombre)
|
|
518
|
+
)
|
|
519
|
+
.scalars()
|
|
520
|
+
.all()
|
|
521
|
+
)
|
|
522
|
+
form.empleado_id.choices = [("", _("-- Seleccionar Empleado --"))] + [
|
|
523
|
+
(e.id, f"{e.codigo_empleado} - {e.primer_nombre} {e.primer_apellido}") for e in empleados
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
# Populate percepcion choices
|
|
527
|
+
percepciones = (
|
|
528
|
+
db.session.execute(db.select(Percepcion).filter(Percepcion.activo.is_(True)).order_by(Percepcion.codigo))
|
|
529
|
+
.scalars()
|
|
530
|
+
.all()
|
|
531
|
+
)
|
|
532
|
+
form.percepcion_id.choices = [("", _("-- Seleccionar Percepción --"))] + [
|
|
533
|
+
(p.id, f"{p.codigo} - {p.nombre}") for p in percepciones
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
# Populate deduccion choices
|
|
537
|
+
deducciones = (
|
|
538
|
+
db.session.execute(db.select(Deduccion).filter(Deduccion.activo.is_(True)).order_by(Deduccion.codigo))
|
|
539
|
+
.scalars()
|
|
540
|
+
.all()
|
|
541
|
+
)
|
|
542
|
+
form.deduccion_id.choices = [("", _("-- Seleccionar Deducción --"))] + [
|
|
543
|
+
(d.id, f"{d.codigo} - {d.nombre}") for d in deducciones
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
if form.validate_on_submit():
|
|
547
|
+
empleado_id = form.empleado_id.data
|
|
548
|
+
empleado = db.session.get(Empleado, empleado_id)
|
|
549
|
+
|
|
550
|
+
if not empleado:
|
|
551
|
+
flash(_("Empleado no encontrado."), "danger")
|
|
552
|
+
return render_template(
|
|
553
|
+
"modules/vacation/register_taken_form.html",
|
|
554
|
+
form=form,
|
|
555
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Validate tipo_concepto and associated percepcion/deduccion
|
|
559
|
+
tipo_concepto = form.tipo_concepto.data
|
|
560
|
+
percepcion_id = form.percepcion_id.data if tipo_concepto == "percepcion" else None
|
|
561
|
+
deduccion_id = form.deduccion_id.data if tipo_concepto == "deduccion" else None
|
|
562
|
+
|
|
563
|
+
if tipo_concepto == "percepcion" and not percepcion_id:
|
|
564
|
+
flash(_("Debe seleccionar una percepción cuando el tipo de concepto es percepción."), "danger")
|
|
565
|
+
return render_template(
|
|
566
|
+
"modules/vacation/register_taken_form.html",
|
|
567
|
+
form=form,
|
|
568
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
if tipo_concepto == "deduccion" and not deduccion_id:
|
|
572
|
+
flash(_("Debe seleccionar una deducción cuando el tipo de concepto es deducción."), "danger")
|
|
573
|
+
return render_template(
|
|
574
|
+
"modules/vacation/register_taken_form.html",
|
|
575
|
+
form=form,
|
|
576
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Get the concepto for codigo
|
|
580
|
+
if tipo_concepto == "percepcion":
|
|
581
|
+
concepto = db.session.get(Percepcion, percepcion_id)
|
|
582
|
+
codigo_concepto = concepto.codigo if concepto else "VACACIONES"
|
|
583
|
+
else:
|
|
584
|
+
concepto = db.session.get(Deduccion, deduccion_id)
|
|
585
|
+
codigo_concepto = concepto.codigo if concepto else "AUSENCIA"
|
|
586
|
+
|
|
587
|
+
# Validate that employee has a vacation account
|
|
588
|
+
account = db.session.execute(
|
|
589
|
+
db.select(VacationAccount).filter(
|
|
590
|
+
VacationAccount.empleado_id == empleado_id, VacationAccount.activo.is_(True)
|
|
591
|
+
)
|
|
592
|
+
).scalar_one_or_none()
|
|
593
|
+
|
|
594
|
+
if not account:
|
|
595
|
+
flash(_("El empleado no tiene una cuenta de vacaciones activa. Cree una cuenta primero."), "danger")
|
|
596
|
+
return render_template(
|
|
597
|
+
"modules/vacation/register_taken_form.html",
|
|
598
|
+
form=form,
|
|
599
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Check balance (considering dias_descontados, not calendar days)
|
|
603
|
+
dias_descontados = form.dias_descontados.data
|
|
604
|
+
if account.current_balance < dias_descontados:
|
|
605
|
+
if not account.policy.allow_negative:
|
|
606
|
+
flash(
|
|
607
|
+
_(f"Saldo insuficiente. Balance actual: {account.current_balance}, Solicitado: {dias_descontados}"),
|
|
608
|
+
"danger",
|
|
609
|
+
)
|
|
610
|
+
return render_template(
|
|
611
|
+
"modules/vacation/register_taken_form.html",
|
|
612
|
+
form=form,
|
|
613
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Create VacationNovelty (leave record)
|
|
617
|
+
vacation_novelty = VacationNovelty(
|
|
618
|
+
empleado_id=empleado_id,
|
|
619
|
+
account_id=account.id,
|
|
620
|
+
start_date=form.fecha_inicio.data,
|
|
621
|
+
end_date=form.fecha_fin.data,
|
|
622
|
+
units=dias_descontados, # CRITICAL: Use dias_descontados, not calendar days
|
|
623
|
+
estado="aprobado", # Directly approved
|
|
624
|
+
fecha_aprobacion=date.today(),
|
|
625
|
+
aprobado_por=current_user.usuario,
|
|
626
|
+
observaciones=form.observaciones.data,
|
|
627
|
+
creado_por=current_user.usuario,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
db.session.add(vacation_novelty)
|
|
631
|
+
db.session.flush() # Get ID
|
|
632
|
+
|
|
633
|
+
# Create VacationLedger entry
|
|
634
|
+
ledger_entry = VacationLedger(
|
|
635
|
+
account_id=account.id,
|
|
636
|
+
empleado_id=empleado_id,
|
|
637
|
+
fecha=form.fecha_fin.data,
|
|
638
|
+
entry_type=VacationLedgerType.USAGE,
|
|
639
|
+
quantity=-abs(dias_descontados), # Negative for usage
|
|
640
|
+
source="direct_registration",
|
|
641
|
+
reference_id=vacation_novelty.id,
|
|
642
|
+
reference_type="vacation_novelty",
|
|
643
|
+
observaciones=f"{form.fecha_inicio.data} - {form.fecha_fin.data} - {dias_descontados} descontados",
|
|
644
|
+
creado_por=current_user.usuario,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Update account balance
|
|
648
|
+
account.current_balance = account.current_balance - abs(dias_descontados)
|
|
649
|
+
account.modificado_por = current_user.usuario
|
|
650
|
+
|
|
651
|
+
db.session.add(ledger_entry)
|
|
652
|
+
db.session.flush()
|
|
653
|
+
|
|
654
|
+
ledger_entry.balance_after = account.current_balance
|
|
655
|
+
|
|
656
|
+
# Link ledger entry to vacation novelty
|
|
657
|
+
vacation_novelty.ledger_entry_id = ledger_entry.id
|
|
658
|
+
vacation_novelty.estado = "disfrutado"
|
|
659
|
+
|
|
660
|
+
# Create associated NominaNovedad using existing infrastructure
|
|
661
|
+
# This ensures the novelty is properly processed during payroll calculation
|
|
662
|
+
nomina_novedad = NominaNovedad(
|
|
663
|
+
nomina_id=None, # Will be linked to the employee's next nomina when calculated
|
|
664
|
+
empleado_id=empleado_id,
|
|
665
|
+
tipo_valor="dias", # Or "horas" based on policy unit_type
|
|
666
|
+
codigo_concepto=codigo_concepto,
|
|
667
|
+
valor_cantidad=dias_descontados,
|
|
668
|
+
fecha_novedad=form.fecha_inicio.data,
|
|
669
|
+
percepcion_id=percepcion_id, # Required association
|
|
670
|
+
deduccion_id=deduccion_id, # Required association
|
|
671
|
+
es_descanso_vacaciones=True,
|
|
672
|
+
vacation_novelty_id=vacation_novelty.id,
|
|
673
|
+
fecha_inicio_descanso=form.fecha_inicio.data,
|
|
674
|
+
fecha_fin_descanso=form.fecha_fin.data,
|
|
675
|
+
estado="pendiente", # Will be processed when nomina is calculated
|
|
676
|
+
creado_por=current_user.usuario,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
db.session.add(nomina_novedad)
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
db.session.commit()
|
|
683
|
+
flash(_(f"Vacaciones registradas exitosamente. {dias_descontados} días descontados del saldo."), "success")
|
|
684
|
+
return redirect(url_for("vacation.account_detail", account_id=account.id))
|
|
685
|
+
except Exception as e:
|
|
686
|
+
db.session.rollback()
|
|
687
|
+
flash(_("Error al registrar vacaciones: {}").format(str(e)), "danger")
|
|
688
|
+
|
|
689
|
+
return render_template(
|
|
690
|
+
"modules/vacation/register_taken_form.html",
|
|
691
|
+
form=form,
|
|
692
|
+
titulo=_("Registrar Vacaciones Descansadas"),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ============================================================================
|
|
697
|
+
# Vacation Dashboard
|
|
698
|
+
# ============================================================================
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@vacation_bp.route("/")
|
|
702
|
+
@login_required
|
|
703
|
+
def dashboard():
|
|
704
|
+
"""Vacation management dashboard."""
|
|
705
|
+
# Statistics
|
|
706
|
+
total_policies = (
|
|
707
|
+
db.session.execute(db.select(func.count(VacationPolicy.id)).filter(VacationPolicy.activo.is_(True))).scalar()
|
|
708
|
+
or 0
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
total_accounts = (
|
|
712
|
+
db.session.execute(db.select(func.count(VacationAccount.id)).filter(VacationAccount.activo.is_(True))).scalar()
|
|
713
|
+
or 0
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
pending_requests = (
|
|
717
|
+
db.session.execute(
|
|
718
|
+
db.select(func.count(VacationNovelty.id)).filter(VacationNovelty.estado == "pendiente")
|
|
719
|
+
).scalar()
|
|
720
|
+
or 0
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# Recent activity
|
|
724
|
+
recent_requests = (
|
|
725
|
+
db.session.execute(
|
|
726
|
+
db.select(VacationNovelty)
|
|
727
|
+
.join(VacationNovelty.empleado)
|
|
728
|
+
.order_by(VacationNovelty.timestamp.desc())
|
|
729
|
+
.limit(10)
|
|
730
|
+
)
|
|
731
|
+
.scalars()
|
|
732
|
+
.all()
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
return render_template(
|
|
736
|
+
"modules/vacation/dashboard.html",
|
|
737
|
+
total_policies=total_policies,
|
|
738
|
+
total_accounts=total_accounts,
|
|
739
|
+
pending_requests=pending_requests,
|
|
740
|
+
recent_requests=recent_requests,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# ============================================================================
|
|
745
|
+
# API Endpoints for AJAX
|
|
746
|
+
# ============================================================================
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@vacation_bp.route("/api/employee/<string:employee_id>/balance")
|
|
750
|
+
@login_required
|
|
751
|
+
def api_employee_balance(employee_id):
|
|
752
|
+
"""Get employee vacation balance (AJAX endpoint)."""
|
|
753
|
+
account = db.session.execute(
|
|
754
|
+
db.select(VacationAccount).filter(VacationAccount.empleado_id == employee_id, VacationAccount.activo.is_(True))
|
|
755
|
+
).scalar_one_or_none()
|
|
756
|
+
|
|
757
|
+
if not account:
|
|
758
|
+
return jsonify({"error": "No vacation account found"}), 404
|
|
759
|
+
|
|
760
|
+
return jsonify(
|
|
761
|
+
{
|
|
762
|
+
"balance": float(account.current_balance),
|
|
763
|
+
"unit_type": account.policy.unit_type,
|
|
764
|
+
"policy_name": account.policy.nombre,
|
|
765
|
+
}
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ============================================================================
|
|
770
|
+
# Initial Balance Loading (for System Implementation)
|
|
771
|
+
# ============================================================================
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@vacation_bp.route("/initial-balance", methods=["GET", "POST"])
|
|
775
|
+
@require_role(TipoUsuario.ADMIN)
|
|
776
|
+
def initial_balance_form():
|
|
777
|
+
"""Load initial vacation balance for a single employee.
|
|
778
|
+
|
|
779
|
+
Used during system implementation to set the initial accumulated vacation
|
|
780
|
+
balance for employees who already have vacation time earned before the
|
|
781
|
+
system goes live.
|
|
782
|
+
|
|
783
|
+
Creates an ADJUSTMENT ledger entry with the initial balance and sets
|
|
784
|
+
the account's current_balance to match.
|
|
785
|
+
"""
|
|
786
|
+
from coati_payroll.forms import VacationInitialBalanceForm
|
|
787
|
+
|
|
788
|
+
form = VacationInitialBalanceForm()
|
|
789
|
+
|
|
790
|
+
# Populate employee choices
|
|
791
|
+
empleados = (
|
|
792
|
+
db.session.execute(
|
|
793
|
+
db.select(Empleado)
|
|
794
|
+
.filter(Empleado.activo.is_(True))
|
|
795
|
+
.order_by(Empleado.primer_apellido, Empleado.primer_nombre)
|
|
796
|
+
)
|
|
797
|
+
.scalars()
|
|
798
|
+
.all()
|
|
799
|
+
)
|
|
800
|
+
form.empleado_id.choices = [("", _("-- Seleccionar Empleado --"))] + [
|
|
801
|
+
(e.id, f"{e.codigo_empleado} - {e.primer_nombre} {e.primer_apellido}") for e in empleados
|
|
802
|
+
]
|
|
803
|
+
|
|
804
|
+
if form.validate_on_submit():
|
|
805
|
+
empleado_id = form.empleado_id.data
|
|
806
|
+
saldo_inicial = form.saldo_inicial.data
|
|
807
|
+
fecha_corte = form.fecha_corte.data
|
|
808
|
+
observaciones = form.observaciones.data or "Carga de saldo inicial al implementar el sistema"
|
|
809
|
+
|
|
810
|
+
# Get employee and their vacation account
|
|
811
|
+
empleado = db.session.get(Empleado, empleado_id)
|
|
812
|
+
if not empleado:
|
|
813
|
+
flash(_("Empleado no encontrado."), "danger")
|
|
814
|
+
return redirect(url_for("vacation.initial_balance_form"))
|
|
815
|
+
|
|
816
|
+
# Check if employee has an active vacation account
|
|
817
|
+
account = db.session.execute(
|
|
818
|
+
db.select(VacationAccount).filter(
|
|
819
|
+
VacationAccount.empleado_id == empleado_id, VacationAccount.activo.is_(True)
|
|
820
|
+
)
|
|
821
|
+
).scalar_one_or_none()
|
|
822
|
+
|
|
823
|
+
if not account:
|
|
824
|
+
flash(
|
|
825
|
+
_(
|
|
826
|
+
"El empleado {} no tiene una cuenta de vacaciones activa. " "Por favor, cree una cuenta primero."
|
|
827
|
+
).format(empleado.codigo_empleado),
|
|
828
|
+
"warning",
|
|
829
|
+
)
|
|
830
|
+
return redirect(url_for("vacation.account_index"))
|
|
831
|
+
|
|
832
|
+
# Check if there are already ledger entries for this account
|
|
833
|
+
existing_entries = (
|
|
834
|
+
db.session.execute(
|
|
835
|
+
db.select(func.count(VacationLedger.id)).filter(VacationLedger.account_id == account.id)
|
|
836
|
+
).scalar()
|
|
837
|
+
or 0
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
if existing_entries > 0:
|
|
841
|
+
flash(
|
|
842
|
+
_(
|
|
843
|
+
"La cuenta de vacaciones del empleado {} ya tiene movimientos registrados. "
|
|
844
|
+
"No se puede cargar un saldo inicial para cuentas con historial."
|
|
845
|
+
).format(empleado.codigo_empleado),
|
|
846
|
+
"warning",
|
|
847
|
+
)
|
|
848
|
+
return redirect(url_for("vacation.account_detail", account_id=account.id))
|
|
849
|
+
|
|
850
|
+
# Create ledger entry for initial balance
|
|
851
|
+
ledger_entry = VacationLedger(
|
|
852
|
+
account_id=account.id,
|
|
853
|
+
empleado_id=empleado_id,
|
|
854
|
+
fecha=fecha_corte,
|
|
855
|
+
entry_type=VacationLedgerType.ADJUSTMENT,
|
|
856
|
+
quantity=saldo_inicial,
|
|
857
|
+
source="initial_balance",
|
|
858
|
+
reference_type="manual",
|
|
859
|
+
observaciones=observaciones,
|
|
860
|
+
creado_por=current_user.usuario,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# Set account balance to initial balance
|
|
864
|
+
account.current_balance = saldo_inicial
|
|
865
|
+
account.last_accrual_date = fecha_corte
|
|
866
|
+
account.modificado_por = current_user.usuario
|
|
867
|
+
|
|
868
|
+
ledger_entry.balance_after = account.current_balance
|
|
869
|
+
|
|
870
|
+
db.session.add(ledger_entry)
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
db.session.commit()
|
|
874
|
+
flash(
|
|
875
|
+
_("Saldo inicial de {} {} cargado exitosamente para {}.").format(
|
|
876
|
+
saldo_inicial, account.policy.unit_type, empleado.codigo_empleado
|
|
877
|
+
),
|
|
878
|
+
"success",
|
|
879
|
+
)
|
|
880
|
+
return redirect(url_for("vacation.account_detail", account_id=account.id))
|
|
881
|
+
except Exception as e:
|
|
882
|
+
db.session.rollback()
|
|
883
|
+
flash(_("Error al cargar saldo inicial: {}").format(str(e)), "danger")
|
|
884
|
+
|
|
885
|
+
return render_template("modules/vacation/initial_balance_form.html", form=form)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@vacation_bp.route("/initial-balance/bulk", methods=["GET", "POST"])
|
|
889
|
+
@require_role(TipoUsuario.ADMIN)
|
|
890
|
+
def initial_balance_bulk():
|
|
891
|
+
"""Bulk load initial vacation balances from Excel.
|
|
892
|
+
|
|
893
|
+
Used during system implementation for companies with many employees.
|
|
894
|
+
Allows uploading an Excel file with initial vacation balances for multiple
|
|
895
|
+
employees at once.
|
|
896
|
+
|
|
897
|
+
Expected Excel format (without headers, data starts on row 1):
|
|
898
|
+
- Column A: Código de Empleado
|
|
899
|
+
- Column B: Saldo Inicial (días/horas)
|
|
900
|
+
- Column C: Fecha de Corte (DD/MM/YYYY)
|
|
901
|
+
- Column D: Observaciones (opcional)
|
|
902
|
+
"""
|
|
903
|
+
if request.method == "POST":
|
|
904
|
+
# Check if file was uploaded
|
|
905
|
+
if "file" not in request.files:
|
|
906
|
+
flash(_("No se seleccionó ningún archivo."), "warning")
|
|
907
|
+
return redirect(url_for("vacation.initial_balance_bulk"))
|
|
908
|
+
|
|
909
|
+
file = request.files["file"]
|
|
910
|
+
|
|
911
|
+
if file.filename == "":
|
|
912
|
+
flash(_("No se seleccionó ningún archivo."), "warning")
|
|
913
|
+
return redirect(url_for("vacation.initial_balance_bulk"))
|
|
914
|
+
|
|
915
|
+
if not file.filename.endswith((".xlsx", ".xls")):
|
|
916
|
+
flash(_("Por favor, suba un archivo Excel (.xlsx o .xls)."), "warning")
|
|
917
|
+
return redirect(url_for("vacation.initial_balance_bulk"))
|
|
918
|
+
|
|
919
|
+
try:
|
|
920
|
+
import openpyxl
|
|
921
|
+
from datetime import datetime as dt
|
|
922
|
+
|
|
923
|
+
# Load Excel file
|
|
924
|
+
workbook = openpyxl.load_workbook(file, data_only=True)
|
|
925
|
+
sheet = workbook.active
|
|
926
|
+
|
|
927
|
+
success_count = 0
|
|
928
|
+
error_count = 0
|
|
929
|
+
errors = []
|
|
930
|
+
|
|
931
|
+
# Process each row (data starts at row 1, no headers expected)
|
|
932
|
+
for row_num, row in enumerate(sheet.iter_rows(min_row=1, values_only=True), start=1):
|
|
933
|
+
codigo_empleado = row[0]
|
|
934
|
+
saldo_inicial = row[1]
|
|
935
|
+
fecha_corte = row[2]
|
|
936
|
+
observaciones = row[3] if len(row) > 3 else "Carga masiva de saldo inicial"
|
|
937
|
+
|
|
938
|
+
# Validate required fields
|
|
939
|
+
if not codigo_empleado or saldo_inicial is None or not fecha_corte:
|
|
940
|
+
errors.append(f"Fila {row_num}: Faltan campos requeridos")
|
|
941
|
+
error_count += 1
|
|
942
|
+
continue
|
|
943
|
+
|
|
944
|
+
# Convert fecha_corte if it's a datetime object
|
|
945
|
+
if isinstance(fecha_corte, dt):
|
|
946
|
+
fecha_corte = fecha_corte.date()
|
|
947
|
+
elif isinstance(fecha_corte, str):
|
|
948
|
+
try:
|
|
949
|
+
fecha_corte = dt.strptime(fecha_corte, "%d/%m/%Y").date()
|
|
950
|
+
except ValueError:
|
|
951
|
+
errors.append(f"Fila {row_num}: Formato de fecha inválido (use DD/MM/YYYY)")
|
|
952
|
+
error_count += 1
|
|
953
|
+
continue
|
|
954
|
+
|
|
955
|
+
# Find employee
|
|
956
|
+
empleado = db.session.execute(
|
|
957
|
+
db.select(Empleado).filter(Empleado.codigo_empleado == codigo_empleado, Empleado.activo.is_(True))
|
|
958
|
+
).scalar_one_or_none()
|
|
959
|
+
|
|
960
|
+
if not empleado:
|
|
961
|
+
errors.append(f"Fila {row_num}: Empleado {codigo_empleado} no encontrado")
|
|
962
|
+
error_count += 1
|
|
963
|
+
continue
|
|
964
|
+
|
|
965
|
+
# Check if employee has an active vacation account
|
|
966
|
+
account = db.session.execute(
|
|
967
|
+
db.select(VacationAccount).filter(
|
|
968
|
+
VacationAccount.empleado_id == empleado.id, VacationAccount.activo.is_(True)
|
|
969
|
+
)
|
|
970
|
+
).scalar_one_or_none()
|
|
971
|
+
|
|
972
|
+
if not account:
|
|
973
|
+
errors.append(f"Fila {row_num}: Empleado {codigo_empleado} no tiene cuenta de vacaciones activa")
|
|
974
|
+
error_count += 1
|
|
975
|
+
continue
|
|
976
|
+
|
|
977
|
+
# Check if account already has ledger entries
|
|
978
|
+
existing_entries = (
|
|
979
|
+
db.session.execute(
|
|
980
|
+
db.select(func.count(VacationLedger.id)).filter(VacationLedger.account_id == account.id)
|
|
981
|
+
).scalar()
|
|
982
|
+
or 0
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
if existing_entries > 0:
|
|
986
|
+
errors.append(f"Fila {row_num}: Empleado {codigo_empleado} ya tiene movimientos en su cuenta")
|
|
987
|
+
error_count += 1
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
# Create ledger entry for initial balance
|
|
992
|
+
ledger_entry = VacationLedger(
|
|
993
|
+
account_id=account.id,
|
|
994
|
+
empleado_id=empleado.id,
|
|
995
|
+
fecha=fecha_corte,
|
|
996
|
+
entry_type=VacationLedgerType.ADJUSTMENT,
|
|
997
|
+
quantity=Decimal(str(saldo_inicial)),
|
|
998
|
+
source="initial_balance_bulk",
|
|
999
|
+
reference_type="excel_import",
|
|
1000
|
+
observaciones=str(observaciones) if observaciones else "Carga masiva de saldo inicial",
|
|
1001
|
+
creado_por=current_user.usuario,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# Set account balance to initial balance
|
|
1005
|
+
account.current_balance = Decimal(str(saldo_inicial))
|
|
1006
|
+
account.last_accrual_date = fecha_corte
|
|
1007
|
+
account.modificado_por = current_user.usuario
|
|
1008
|
+
|
|
1009
|
+
ledger_entry.balance_after = account.current_balance
|
|
1010
|
+
|
|
1011
|
+
db.session.add(ledger_entry)
|
|
1012
|
+
success_count += 1
|
|
1013
|
+
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
errors.append(f"Fila {row_num}: Error al procesar {codigo_empleado}: {str(e)}")
|
|
1016
|
+
error_count += 1
|
|
1017
|
+
# Don't rollback here, continue adding successful entries
|
|
1018
|
+
continue
|
|
1019
|
+
|
|
1020
|
+
# Commit all changes
|
|
1021
|
+
try:
|
|
1022
|
+
db.session.commit()
|
|
1023
|
+
flash(
|
|
1024
|
+
_("Carga completada: {} registros exitosos, {} errores.").format(success_count, error_count),
|
|
1025
|
+
"success" if error_count == 0 else "warning",
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
if errors:
|
|
1029
|
+
error_details = "<br>".join(errors[:10]) # Show first 10 errors
|
|
1030
|
+
if len(errors) > 10:
|
|
1031
|
+
error_details += f"<br>...y {len(errors) - 10} errores más"
|
|
1032
|
+
flash(error_details, "warning")
|
|
1033
|
+
|
|
1034
|
+
except Exception as e:
|
|
1035
|
+
db.session.rollback()
|
|
1036
|
+
flash(_("Error al guardar los cambios: {}").format(str(e)), "danger")
|
|
1037
|
+
|
|
1038
|
+
except ImportError:
|
|
1039
|
+
flash(_("Error: La librería openpyxl no está instalada. Contacte al administrador."), "danger")
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
flash(_("Error al procesar el archivo Excel: {}").format(str(e)), "danger")
|
|
1042
|
+
|
|
1043
|
+
return redirect(url_for("vacation.initial_balance_bulk"))
|
|
1044
|
+
|
|
1045
|
+
return render_template("modules/vacation/initial_balance_bulk.html")
|