iteracion 2

This commit is contained in:
Nachou-u
2026-09-07 18:44:32 -03:00
parent a103a47ae6
commit 09a3f6fb37
93 changed files with 7394 additions and 349 deletions
+326 -28
View File
@@ -1,10 +1,13 @@
import csv
import io
from decimal import Decimal
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
@@ -37,6 +40,7 @@ def logout_view(request):
@login_required
@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad)
def dashboard(request):
today = date.today()
first_day_of_month = today.replace(day=1)
@@ -49,18 +53,21 @@ def dashboard(request):
total=Sum('amount_clp')
)['total'] or 0
project_count = Project.objects.count()
# 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]
# Presupuesto Global
setting, _ = SystemSetting.objects.get_or_create(id=1)
global_budget = setting.global_budget
remaining_budget = global_budget - total_all_clp
# 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]
@@ -74,11 +81,13 @@ def dashboard(request):
context = {
'total_month': total_month_clp,
'total_all': total_all_clp,
'project_count': project_count,
'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,
'global_budget': global_budget,
'remaining_budget': remaining_budget,
'category_labels_json': json.dumps(category_labels),
'category_data_json': json.dumps(category_data),
'project_labels_json': json.dumps(project_labels),
@@ -111,6 +120,7 @@ def expense_list(request):
@login_required
@transaction.atomic # Integridad de datos
def expense_create(request):
# Only allow creation if user is admin or allowed to
if request.method == 'POST':
@@ -419,13 +429,49 @@ def export_bank_transfer_csv(request):
@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')
).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)
@@ -467,34 +513,125 @@ def settings_edit(request):
@login_required
@role_required('Administrador')
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, 'Proyecto creado exitosamente.')
return redirect('gestion_panel')
messages.success(request, f"Proyecto {project.code} - {project.name} creado exitosamente.")
return redirect('project_detail', pk=project.pk)
else:
form = ProjectForm()
return render(request, 'expenses/project_form.html', {'form': form})
# 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
@role_required('Administrador')
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, 'Proyecto actualizado exitosamente.')
return redirect('gestion_panel')
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})
return render(request, 'expenses/project_form.html', {'form': form, 'project': project, 'title': f'Editar Proyecto: {project.code}'})
@login_required
@@ -504,7 +641,7 @@ def project_delete(request, pk):
if request.method == 'POST':
project.delete()
messages.success(request, 'Proyecto eliminado.')
return redirect('gestion_panel')
return redirect('project_list')
@login_required
@@ -547,32 +684,59 @@ def category_delete(request, pk):
@login_required
@role_required('Administrador')
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():
form.save()
messages.success(request, 'Proveedor creado exitosamente.')
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:
form = VendorForm()
return render(request, 'expenses/vendor_form.html', {'form': form})
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
@role_required('Administrador')
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, 'Proveedor actualizado exitosamente.')
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})
return render(request, 'expenses/vendor_form.html', {'form': form, 'title': f'Editar Proveedor: {vendor.rut}'})
@login_required
@@ -706,3 +870,137 @@ def delete_document(request, pk):
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']
})