iteracion 2
This commit is contained in:
+326
-28
@@ -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']
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user