1014 lines
40 KiB
Python
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']
|
|
})
|
|
|