Files
erp-efintel-2026-portainer/expenses/views.py
T

1014 lines
40 KiB
Python

import csv
import io
from decimal import Decimal
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .mixins import RoleRequiredMixin
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page
from django.contrib.auth import login, authenticate, logout
from django.contrib import messages
from django.db.models import Sum, Count, F
from django.db import transaction
from django.utils import timezone
from datetime import date
from django.http import JsonResponse, HttpResponse
import json
import os
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum, Vendor, Document
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm, CategoryForm, VendorForm
from .decorators import role_required
from django.contrib.auth.models import User, Group
from .chile_regions import REGIONS_COMMUNES
def login_view(request):
if request.user.is_authenticated:
return redirect('dashboard')
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect('dashboard')
else:
messages.error(request, 'Usuario o contraseña incorrectos.')
return render(request, 'registration/login.html')
def logout_view(request):
logout(request)
return redirect('login')
@login_required
@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad)
def dashboard(request):
today = date.today()
first_day_of_month = today.replace(day=1)
total_month_clp = Expense.objects.filter(
date__gte=first_day_of_month
).aggregate(total=Sum('amount_clp'))['total'] or 0
total_all_clp = Expense.objects.aggregate(
total=Sum('amount_clp')
)['total'] or 0
# Requerimiento 2: Conteo exacto de proyectos reales (excluyendo portafolios)
from .models import ProjectStatusEnum
real_projects = Project.objects.filter(parent__parent__isnull=False)
project_count_active = real_projects.filter(status=ProjectStatusEnum.ACTIVO).count()
project_count_paused = real_projects.filter(status=ProjectStatusEnum.PAUSADA).count()
project_count_completed = real_projects.filter(status=ProjectStatusEnum.TERMINADO).count()
project_count_draft = real_projects.filter(status=ProjectStatusEnum.BORRADOR).count()
project_count_total = real_projects.count()
today_count = Expense.objects.filter(date=today).count()
recent_expenses = Expense.objects.select_related(
'project', 'category', 'user', 'responsible_person'
).order_by('-date')[:10]
# Chart Data: Gastos por Categoría
cat_expenses = Expense.objects.values('category__name').annotate(total=Sum('amount_clp')).order_by('-total')
category_labels = [item['category__name'] or 'Sin Categoría' for item in cat_expenses]
category_data = [float(item['total'] or 0) for item in cat_expenses]
# Chart Data: Gastos por Proyecto
proj_expenses = Expense.objects.values('project__name').annotate(total=Sum('amount_clp')).order_by('-total')
project_labels = [item['project__name'] or 'Sin Proyecto' for item in proj_expenses]
project_data = [float(item['total'] or 0) for item in proj_expenses]
context = {
'total_month': total_month_clp,
'total_all': total_all_clp,
'project_count_active': project_count_active,
'project_count_paused': project_count_paused,
'project_count_completed': project_count_completed,
'project_count_draft': project_count_draft,
'project_count_total': project_count_total,
'today_count': today_count,
'recent_expenses': recent_expenses,
'category_labels_json': json.dumps(category_labels),
'category_data_json': json.dumps(category_data),
'project_labels_json': json.dumps(project_labels),
'project_data_json': json.dumps(project_data),
}
return render(request, 'expenses/dashboard.html', context)
class ExpenseListView(LoginRequiredMixin, ListView):
model = Expense
template_name = 'expenses/expense_list.html'
context_object_name = 'expenses'
def get_queryset(self):
queryset = super().get_queryset().select_related('project', 'category', 'user').order_by('-date')
project_id = self.request.GET.get('project')
category_id = self.request.GET.get('category')
if project_id:
queryset = queryset.filter(project_id=project_id)
if category_id:
queryset = queryset.filter(category_id=category_id)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['projects'] = Project.objects.all()
context['categories'] = Category.objects.all()
context['selected_project'] = self.request.GET.get('project')
context['selected_category'] = self.request.GET.get('category')
return context
from django.utils.decorators import method_decorator
@method_decorator(transaction.atomic, name='dispatch')
class ExpenseCreateView(LoginRequiredMixin, CreateView):
model = Expense
form_class = ExpenseForm
template_name = 'expenses/expense_form.html'
success_url = reverse_lazy('expense_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['regions_communes_json'] = json.dumps(REGIONS_COMMUNES)
return context
def form_valid(self, form):
expense = form.save(commit=False)
expense.user = self.request.user
setting, _ = SystemSetting.objects.get_or_create(id=1)
if expense.currency == CurrencyEnum.CLP:
expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
else:
expense.amount_usd = expense.amount_clp
expense.amount_clp = expense.amount_usd * setting.usd_exchange_rate
expense.save()
receipts = self.request.FILES.getlist('receipt')
if receipts:
from .models import Document
import os
cat_name = expense.category.name if expense.category else 'Evidencia'
proj_code = expense.project.code if expense.project else 'PROY'
for idx, f in enumerate(receipts, start=1):
ext = os.path.splitext(f.name)[1].lower()
semantic_title = f"{proj_code} · {cat_name} · Doc {idx:02d}{ext}"
Document.objects.create(
expense=expense,
file=f,
title=semantic_title,
uploaded_by=self.request.user
)
AuditLog.objects.create(
user=self.request.user,
action="Gasto Creado",
entity_name="Expense",
entity_id=expense.expense_code,
description=f"Se creó el gasto por {expense.amount_clp} CLP"
)
messages.success(self.request, 'Gasto registrado exitosamente.')
return super().form_valid(form)
@login_required
def expense_edit(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups or expense.user == request.user):
messages.error(request, 'No tienes permisos para editar este gasto.')
return redirect('expense_list')
if expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
messages.error(request, 'Gasto bloqueado. No puedes editar un comprobante que ya está en finanzas o pagado.')
return redirect('expense_list')
old_amount = expense.amount_clp
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES, instance=expense)
if form.is_valid():
exp = form.save(commit=False)
setting, _ = SystemSetting.objects.get_or_create(id=1)
exp.amount_usd = exp.amount_clp / setting.usd_exchange_rate
# Reset status if it was rejected
if exp.approval_status == ApprovalStatusEnum.OBSERVADO_RECHAZADO:
exp.approval_status = ApprovalStatusEnum.EN_REVISION
exp.save()
receipts = request.FILES.getlist('receipt')
if receipts:
from .models import Document
import os
cat_name = exp.category.name if exp.category else 'Evidencia'
proj_code = exp.project.code if exp.project else 'PROY'
existing_count = exp.documents.count()
for idx, f in enumerate(receipts, start=existing_count + 1):
ext = os.path.splitext(f.name)[1].lower()
semantic_title = f"{proj_code} · {cat_name} · Doc {idx:02d}{ext}"
Document.objects.create(
expense=exp,
file=f,
title=semantic_title,
uploaded_by=request.user
)
AuditLog.objects.create(
user=request.user,
action="Gasto Editado",
entity_name="Expense",
entity_id=exp.expense_code,
field_changed="amount_clp",
old_value=str(old_amount),
new_value=str(exp.amount_clp),
description=f"Se editó el monto del gasto"
)
messages.success(request, 'Gasto actualizado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm(instance=expense)
context = {
'form': form,
'regions_communes_json': json.dumps(REGIONS_COMMUNES)
}
return render(request, 'expenses/expense_form.html', context)
@login_required
def expense_delete(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups):
messages.error(request, 'Solo administradores pueden eliminar gastos.')
return redirect('expense_list')
if expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
messages.error(request, 'Gasto bloqueado. No puedes eliminar un comprobante que ya está en finanzas o pagado.')
return redirect('expense_list')
if request.method == 'POST':
code = expense.expense_code
expense.delete()
AuditLog.objects.create(
user=request.user,
action="Gasto Eliminado",
entity_name="Expense",
entity_id=code,
description="Se eliminó el gasto de la base de datos"
)
messages.success(request, 'Gasto eliminado.')
return redirect('expense_list')
# Flujo de 3 pasos
@login_required
def approve_expense(request, pk, action):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
is_finanzas = 'Administrador Finanzas' in user_groups or is_admin
if action == 'solicitar':
# Step 1 -> 2
if not is_admin and expense.project.manager != request.user:
messages.error(request, "Solo el Administrador o el Encargado del Proyecto pueden solicitar pago a finanzas.")
return redirect('expense_list')
expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
expense.elevated_by = request.user
expense.save()
AuditLog.objects.create(
user=request.user,
action="ELEVATE_REQUEST",
entity_name="Expense",
entity_id=expense.expense_code,
description="Solicitud elevada a finanzas"
)
messages.success(request, "Solicitud elevada a finanzas.")
elif action == 'pagar':
# Step 2 -> 3
if not is_finanzas:
messages.error(request, "Solo Finanzas puede liberar el pago.")
return redirect('dashboard')
expense.approval_status = ApprovalStatusEnum.LIBERADO_PAGADO
expense.paid_by = request.user
expense.paid_at = timezone.now()
expense.save()
# Update Project Cost
proj = expense.project
if expense.tax_type == TaxTypeEnum.IVA_19:
proj.actual_cost_clp = proj.actual_cost_clp + expense.amount_clp
else:
proj.actual_cost_clp = proj.actual_cost_clp + expense.total_amount_clp
proj.save()
AuditLog.objects.create(
user=request.user,
action="APPROVE_PAYMENT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Pago liberado"
)
messages.success(request, "Pago liberado con éxito.")
elif action == 'rechazar':
expense.approval_status = ApprovalStatusEnum.OBSERVADO_RECHAZADO
expense.save()
AuditLog.objects.create(
user=request.user,
action="REJECT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Gasto observado/rechazado"
)
messages.warning(request, "Gasto rechazado.")
return redirect(request.META.get('HTTP_REFERER', 'dashboard'))
@login_required
def export_expenses_csv(request):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="Gastos_EFINTEL_{date.today()}.csv"'
writer = csv.writer(response, delimiter=';')
writer.writerow([
'ID_Gasto', 'Codigo_Transaccion', 'Fecha', 'Proyecto_Codigo', 'Proyecto_Nombre',
'Monto_CLP', 'Monto_USD', 'Moneda_Original', 'Estado_Aprobacion',
'Responsable', 'Detalle', 'Region', 'Comuna', 'Proveedor'
])
expenses = Expense.objects.all().select_related('project', 'responsible_person')
for exp in expenses:
writer.writerow([
exp.id,
exp.expense_code,
exp.date,
exp.project.code,
exp.project.name,
exp.amount_clp,
exp.amount_usd,
exp.currency,
exp.get_approval_status_display(),
exp.responsible_person.username if exp.responsible_person else '',
exp.description,
exp.region,
exp.comuna,
exp.vendor
])
AuditLog.objects.create(
user=request.user,
action="EXPORT_CSV",
description="Se descargó la matriz de gastos en formato CSV."
)
return response
@login_required
@role_required('Administrador', 'Administrador Finanzas')
def export_bank_transfer_csv(request):
# Get all expenses requested for payment that are linked to a vendor
pending_expenses = Expense.objects.filter(
approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS,
vendor__isnull=False
)
# Group debts by vendor to perform single bank transfer per supplier
debts = pending_expenses.values(
'vendor__id', 'vendor__rut', 'vendor__name', 'vendor__bank', 'vendor__account_type', 'vendor__account_number'
).annotate(
total_clp=Sum('amount_clp'),
total_usd=Sum('amount_usd'),
expense_count=Count('id')
).order_by('vendor__name')
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="Nomina_Transferencias_{date.today()}.csv"'
# Write BOM for Excel Spanish compatibility
response.write('\ufeff'.encode('utf-8'))
writer = csv.writer(response, delimiter=';')
writer.writerow([
'RUT Proveedor', 'Nombre / Razón Social', 'Banco', 'Tipo Cuenta',
'Número Cuenta', 'Monto Transferir (CLP)', 'Monto Transferir (USD)',
'Comprobantes Pendientes', 'Glosa / Comentario'
])
for debt in debts:
writer.writerow([
debt['vendor__rut'],
debt['vendor__name'],
debt['vendor__bank'] or 'No Registrado',
debt['vendor__account_type'] or 'No Registrado',
debt['vendor__account_number'] or 'No Registrado',
int(debt['total_clp']),
round(debt['total_usd'], 2),
debt['expense_count'],
'Pago Facturas Pendientes EFINTEL ERP'
])
AuditLog.objects.create(
user=request.user,
action="EXPORT_BANK_TRANSFER",
description="Se exportó la nómina bancaria de transferencias a proveedores."
)
return response
@login_required
def project_list(request):
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups
projects = Project.objects.annotate(
expense_count=Count('expenses'),
total_expenses=Sum('expenses__amount_clp')
).select_related('manager', 'category', 'parent').prefetch_related('phases', 'expenses').filter(
parent__parent__isnull=False
).order_by('-created_at')
# If PM and not admin, show managed or assigned projects
from django.db.models import Q
if is_pm and not is_admin:
projects = projects.filter(Q(manager=request.user) | Q(created_by=request.user))
# Filters
status_filter = request.GET.get('status')
search_query = request.GET.get('search')
if status_filter:
projects = projects.filter(status=status_filter)
if search_query:
projects = projects.filter(Q(name__icontains=search_query) | Q(code__icontains=search_query))
# Requerimiento 2: Conteos exactos por estado
from .models import ProjectStatusEnum
project_count_active = projects.filter(status=ProjectStatusEnum.ACTIVO).count()
project_count_paused = projects.filter(status=ProjectStatusEnum.PAUSADA).count()
project_count_completed = projects.filter(status=ProjectStatusEnum.TERMINADO).count()
project_count_draft = projects.filter(status=ProjectStatusEnum.BORRADOR).count()
project_count_total = projects.count()
context = {
'projects': projects,
'is_admin': is_admin,
'is_pm': is_pm,
'project_count_active': project_count_active,
'project_count_paused': project_count_paused,
'project_count_completed': project_count_completed,
'project_count_draft': project_count_draft,
'project_count_total': project_count_total,
}
return render(request, 'expenses/project_list.html', context)
@login_required
@role_required('Administrador')
def gestion_panel(request):
projects = Project.objects.all().select_related('manager', 'created_by', 'parent', 'category')
categories = Category.objects.all().select_related('parent')
vendors = Vendor.objects.all()
responsables = User.objects.all().select_related('profile').prefetch_related('groups', 'profile__specialties')
settings_obj, _ = SystemSetting.objects.get_or_create(id=1)
logs = AuditLog.objects.all().select_related('user')[:100]
context = {
'projects': projects,
'categories': categories,
'vendors': vendors,
'responsables': responsables,
'settings': settings_obj,
'logs': logs,
}
return render(request, 'expenses/gestion.html', context)
@login_required
@role_required('Administrador')
def settings_edit(request):
setting, _ = SystemSetting.objects.get_or_create(id=1)
if request.method == 'POST':
form = SystemSettingForm(request.POST, instance=setting)
if form.is_valid():
form.save()
messages.success(request, 'Configuración actualizada.')
return redirect('gestion_panel')
else:
form = SystemSettingForm(instance=setting)
return render(request, 'expenses/settings_form.html', {'form': form})
@login_required
def project_detail(request, pk):
"""
Requerimiento 9 & 10: Detalle del Proyecto con Presupuesto asignado, Forma de pago,
Ingresos, Egresos, Saldo neto y Fases de pago con saldo a favor (+ / -).
"""
project = get_object_or_404(
Project.objects.select_related('manager', 'category', 'parent', 'created_by')
.prefetch_related('phases', 'expenses__vendor', 'expenses__documents', 'purchase_orders'),
pk=pk
)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups
phases = project.phases.all()
expenses = project.expenses.select_related('vendor', 'category', 'user').order_by('-date')
# Calculations
total_income = project.total_income_clp or Decimal('0.00')
total_expenses = project.total_expenses_clp or Decimal('0.00')
net_balance = project.balance_clp
budget_balance = project.budget_balance_clp
context = {
'project': project,
'phases': phases,
'expenses': expenses,
'total_income': total_income,
'total_expenses': total_expenses,
'net_balance': net_balance,
'budget_balance': budget_balance,
'is_admin': is_admin,
'is_pm': is_pm,
}
return render(request, 'expenses/project_detail.html', context)
@login_required
def project_add_phase(request, pk):
"""
Requerimiento 10: Agregar / actualizar fases de pago para un proyecto.
"""
project = get_object_or_404(Project, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
if not (is_admin or project.manager == request.user or 'Director de Proyecto' in user_groups):
messages.error(request, 'No tienes permisos para modificar las fases de este proyecto.')
return redirect('project_detail', pk=pk)
from .forms import ProjectPhaseForm
if request.method == 'POST':
form = ProjectPhaseForm(request.POST)
if form.is_valid():
phase = form.save(commit=False)
phase.project = project
phase.save()
messages.success(request, f"Fase {phase.phase_number} ({phase.name}) agregada al proyecto.")
return redirect('project_detail', pk=pk)
else:
next_num = project.phases.count() + 1
form = ProjectPhaseForm(initial={'phase_number': next_num})
return render(request, 'expenses/project_phase_form.html', {'form': form, 'project': project})
@login_required
def project_create(request):
"""
Requerimiento 8: Creación progresiva para director de proyectos y RBAC granular.
"""
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups or is_admin
if not is_pm:
messages.error(request, 'No tienes permisos para crear proyectos.')
return redirect('project_list')
if request.method == 'POST':
form = ProjectForm(request.POST)
if form.is_valid():
project = form.save(commit=False)
project.created_by = request.user
if not project.manager and is_pm and not is_admin:
project.manager = request.user
project.save()
messages.success(request, f"Proyecto {project.code} - {project.name} creado exitosamente.")
return redirect('project_detail', pk=project.pk)
else:
# Default code generation
count = Project.objects.count() + 1
default_code = f"PROY-2026-{count:03d}"
form = ProjectForm(initial={'code': default_code, 'manager': request.user})
return render(request, 'expenses/project_form.html', {'form': form, 'title': 'Nuevo Proyecto / Centro de Costo'})
@login_required
def project_edit(request, pk):
project = get_object_or_404(Project, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups or is_admin
if not is_pm:
messages.error(request, 'No tienes permisos para editar este proyecto.')
return redirect('project_list')
if request.method == 'POST':
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
messages.success(request, f"Proyecto {project.code} actualizado exitosamente.")
return redirect('project_detail', pk=project.pk)
else:
form = ProjectForm(instance=project)
return render(request, 'expenses/project_form.html', {'form': form, 'project': project, 'title': f'Editar Proyecto: {project.code}'})
@login_required
@role_required('Administrador')
def project_delete(request, pk):
project = get_object_or_404(Project, pk=pk)
if request.method == 'POST':
project.delete()
messages.success(request, 'Proyecto eliminado.')
return redirect('project_list')
@login_required
@role_required('Administrador')
def category_create(request):
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Categoría creada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm()
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
form = CategoryForm(request.POST, instance=category)
if form.is_valid():
form.save()
messages.success(request, 'Categoría actualizada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm(instance=category)
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
category.delete()
messages.success(request, 'Categoría eliminada.')
return redirect('gestion_panel')
@login_required
def vendor_create(request):
"""
Requerimiento 6: Plan de mitigación de errores para registro de proveedores.
Preserva datos en borrador (session/context) si ocurre un fallo o error de validación.
"""
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
if not is_admin:
messages.error(request, 'No tienes permisos para registrar proveedores.')
return redirect('dashboard')
draft_data = request.session.get('vendor_draft', {})
if request.method == 'POST':
form = VendorForm(request.POST)
if form.is_valid():
vendor = form.save()
if 'vendor_draft' in request.session:
del request.session['vendor_draft']
messages.success(request, f"Proveedor {vendor.rut} - {vendor.name} registrado exitosamente.")
return redirect('gestion_panel')
else:
# Save POST data into session as draft mitigation
request.session['vendor_draft'] = request.POST.dict()
messages.warning(request, "Error en el formulario. Los datos ingresados han sido preservados para prevenir pérdida de información.")
else:
initial = draft_data if draft_data else None
form = VendorForm(initial=initial)
return render(request, 'expenses/vendor_form.html', {'form': form, 'title': 'Nuevo Proveedor', 'has_draft': bool(draft_data)})
@login_required
def vendor_edit(request, pk):
vendor = get_object_or_404(Vendor, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
if not is_admin:
messages.error(request, 'No tienes permisos para editar proveedores.')
return redirect('dashboard')
if request.method == 'POST':
form = VendorForm(request.POST, instance=vendor)
if form.is_valid():
form.save()
messages.success(request, f"Proveedor {vendor.rut} actualizado exitosamente.")
return redirect('gestion_panel')
else:
messages.warning(request, "Error de validación al actualizar el proveedor. Se conservan los cambios parciales.")
else:
form = VendorForm(instance=vendor)
return render(request, 'expenses/vendor_form.html', {'form': form, 'title': f'Editar Proveedor: {vendor.rut}'})
@login_required
@role_required('Administrador')
def vendor_delete(request, pk):
vendor = get_object_or_404(Vendor, pk=pk)
if request.method == 'POST':
vendor.delete()
messages.success(request, 'Proveedor eliminado.')
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def responsable_create(request):
if request.method == 'POST':
form = ResponsableForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Responsable creado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm()
return render(request, 'expenses/responsable_form.html', {'form': form})
@login_required
@role_required('Administrador')
def responsable_edit(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
form = ResponsableForm(request.POST, instance=user)
if form.is_valid():
form.save()
messages.success(request, 'Responsable actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm(instance=user)
return render(request, 'expenses/responsable_form.html', {'form': form, 'edit_mode': True, 'target_user': user})
@login_required
@role_required('Administrador')
def responsable_delete(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
user.delete()
messages.success(request, 'Responsable eliminado.')
return redirect('gestion_panel')
from django.db.models import Sum, Count
@login_required
def accounts_payable(request):
user_groups = request.user.groups.values_list('name', flat=True)
is_finanzas = 'Administrador Finanzas' in user_groups or request.user.is_superuser or 'Administrador' in user_groups
if not is_finanzas:
messages.error(request, 'No tienes permisos para ver Cuentas por Pagar.')
return redirect('dashboard')
debts = Expense.objects.filter(approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS).exclude(vendor__isnull=True).values(
'vendor__id', 'vendor__rut', 'vendor__name', 'vendor__bank', 'vendor__account_type', 'vendor__account_number'
).annotate(
total_clp=Sum('total_amount_clp'),
total_usd=Sum('amount_usd'),
expense_count=Count('id')
).order_by('-total_clp')
return render(request, 'expenses/accounts_payable.html', {'debts': debts})
@login_required
@role_required('Administrador')
def category_create(request):
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Categoría creada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm()
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
form = CategoryForm(request.POST, instance=category)
if form.is_valid():
form.save()
messages.success(request, 'Categoría actualizada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm(instance=category)
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
category.delete()
messages.success(request, 'Categoría eliminada.')
return redirect('gestion_panel')
@login_required
def delete_document(request, pk):
if request.method == 'POST':
doc = get_object_or_404(Document, pk=pk)
# Permissions check
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups or doc.expense.user == request.user):
return JsonResponse({'success': False, 'error': 'No tienes permisos para eliminar este documento.'}, status=403)
# Status check
if doc.expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
return JsonResponse({'success': False, 'error': 'No puedes eliminar evidencias de un gasto en finanzas o pagado.'}, status=400)
# Delete file
if doc.file:
try:
if os.path.exists(doc.file.path):
os.remove(doc.file.path)
except Exception:
pass
# Delete object
doc.delete()
return JsonResponse({'success': True})
return JsonResponse({'success': False, 'error': 'Método no permitido.'}, status=405)
# ----------------- EXPENSE REPORTS (RENDICIONES AGRUPADAS) -----------------
from .models import ExpenseReport, VehicleLog, ExpenseReportStatusEnum
from .forms import ExpenseReportForm, VehicleLogForm
from .services.sii import SIIService
@login_required
def expense_report_list(request):
reports = ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses').all()
# Filter by user if not admin
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
status_filter = request.GET.get('status')
if status_filter:
reports = reports.filter(status=status_filter)
return render(request, 'expenses/expense_report_list.html', {
'reports': reports,
'selected_status': status_filter,
'is_admin': is_admin,
})
@login_required
def expense_report_create(request):
if request.method == 'POST':
form = ExpenseReportForm(request.POST)
if form.is_valid():
rep = form.save(commit=False)
rep.user = request.user
count = ExpenseReport.objects.count() + 1
rep.report_code = f"EXP-REP-2026-{count:04d}"
rep.status = ExpenseReportStatusEnum.BORRADOR
rep.save()
messages.success(request, f"Informe de Rendición {rep.report_code} creado exitosamente. Ahora puede adjuntar gastos.")
return redirect('expense_report_detail', pk=rep.pk)
else:
form = ExpenseReportForm()
return render(request, 'expenses/expense_report_form.html', {'form': form, 'title': 'Nuevo Informe de Rendición de Gastos'})
@login_required
def expense_report_detail(request, pk):
report = get_object_or_404(ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses__vendor', 'expenses__category', 'expenses__documents', 'expenses__vehicle_log'), pk=pk)
# Available standalone expenses for this project and user
available_expenses = Expense.objects.filter(
report__isnull=True,
user=report.user,
project=report.project
).order_by('-date')
return render(request, 'expenses/expense_report_detail.html', {
'report': report,
'available_expenses': available_expenses,
})
@login_required
def expense_report_action(request, pk, action):
report = get_object_or_404(ExpenseReport, pk=pk)
if request.method == 'POST':
if action == 'submit':
report.status = ExpenseReportStatusEnum.ENVIADO
messages.success(request, f"Rendición {report.report_code} enviada a revisión y aprobación de Finanzas.")
elif action == 'approve':
report.status = ExpenseReportStatusEnum.APROBADO
report.approved_by = request.user
report.approved_at = timezone.now()
# Also elevate all child expenses to SOLICITADO_FINANZAS
report.expenses.update(approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS, elevated_by=request.user)
messages.success(request, f"Rendición {report.report_code} aprobada por Finanzas.")
elif action == 'reimburse':
report.status = ExpenseReportStatusEnum.REEMBOLSADO
report.expenses.update(approval_status=ApprovalStatusEnum.LIBERADO_PAGADO, paid_by=request.user, paid_at=timezone.now())
messages.success(request, f"Rendición {report.report_code} marcada como Reembolsada / Pagada.")
elif action == 'observe':
report.status = ExpenseReportStatusEnum.OBSERVADO
report.notes += f"\nObservación ({timezone.now().strftime('%d/%m/%Y')}) por {request.user.username}: " + request.POST.get('reason', '')
messages.warning(request, f"Rendición {report.report_code} observada.")
elif action == 'add_expense':
expense_id = request.POST.get('expense_id')
if expense_id:
exp = Expense.objects.filter(id=expense_id, user=report.user).first()
if exp:
exp.report = report
exp.save(update_fields=['report'])
messages.success(request, f"Gasto {exp.expense_code} añadido a la rendición.")
elif action == 'remove_expense':
expense_id = request.POST.get('expense_id')
if expense_id:
exp = Expense.objects.filter(id=expense_id, report=report).first()
if exp:
exp.report = None
exp.save(update_fields=['report'])
messages.success(request, f"Gasto {exp.expense_code} desvinculado de la rendición.")
report.save()
return redirect('expense_report_detail', pk=pk)
# API / AJAX helper for SII RUT check, taxpayer info & tax calculation
@login_required
def api_sii_validate_rut(request):
rut = request.GET.get('rut', '')
is_valid, formatted = SIIService.validate_rut(rut)
return JsonResponse({'valid': is_valid, 'formatted': formatted})
@login_required
def api_sii_taxpayer_info(request):
"""
Requerimiento 4: Información e integración con la API del SII.
"""
rut = request.GET.get('rut', '')
info = SIIService.get_taxpayer_info(rut)
return JsonResponse(info)
@login_required
def api_sii_calculate_taxes(request):
net_str = request.GET.get('net_amount', '0').replace('.', '').replace(',', '.')
tax_type = request.GET.get('tax_type', 'IVA_19')
try:
net = Decimal(net_str)
except Exception:
net = Decimal('0')
res = SIIService.calculate_taxes(net, tax_type)
return JsonResponse({
'tax_type': res['tax_type'],
'net_amount': float(res['net_amount']),
'tax_rate_percent': float(res.get('tax_rate_percent', 19.0)),
'tax_amount': float(res['tax_amount']),
'total_amount': float(res['total_amount']),
'description': res['description']
})