From 08a26cbf2fd79da422803daeaf0330345c822726 Mon Sep 17 00:00:00 2001 From: Nachou-u Date: Thu, 20 Aug 2026 19:43:56 -0400 Subject: [PATCH] mvp --- expenses/chile_regions.py | 27 ++ expenses/forms.py | 30 +- .../0007_alter_expense_vehicle_ownership.py | 18 ++ expenses/models.py | 8 +- expenses/urls.py | 1 + expenses/views.py | 60 +++- seed_projects.py | 67 ++++- templates/expenses/expense_form.html | 282 ++++++++++++++---- 8 files changed, 413 insertions(+), 80 deletions(-) create mode 100644 expenses/chile_regions.py create mode 100644 expenses/migrations/0007_alter_expense_vehicle_ownership.py diff --git a/expenses/chile_regions.py b/expenses/chile_regions.py new file mode 100644 index 0000000..04359ee --- /dev/null +++ b/expenses/chile_regions.py @@ -0,0 +1,27 @@ +# Dictionary mapping Chilean regions to their communes (simplified but covering major locations for engineering/project sites) + +REGIONS_COMMUNES = { + "Arica y Parinacota": ["Arica", "Camarones", "Putre", "General Lagos"], + "Tarapacá": ["Iquique", "Alto Hospicio", "Pozo Almonte", "Camiña", "Colchane", "Huara", "Pica"], + "Antofagasta": ["Antofagasta", "Mejillones", "Sierra Gorda", "Taltal", "Calama", "Ollagüe", "San Pedro de Atacama", "Tocopilla", "María Elena"], + "Atacama": ["Copiapó", "Caldera", "Tierra Amarilla", "Chañaral", "Diego de Almagro", "Vallenar", "Alto del Carmen", "Freirina", "Huasco"], + "Coquimbo": ["La Serena", "Coquimbo", "Andacollo", "La Higuera", "Paiguano", "Vicuña", "Illapel", "Canela", "Los Vilos", "Salamanca", "Ovalle", "Combarbalá", "Monte Patria", "Punitaqui", "Río Hurtado"], + "Valparaíso": ["Valparaíso", "Casablanca", "Concón", "Juan Fernández", "Puchuncaví", "Quintero", "Viña del Mar", "Isla de Pascua", "Los Andes", "Calle Larga", "Rinconada", "San Esteban", "La Ligua", "Cabildo", "Papudo", "Petorca", "Zapallar", "Quillota", "Calera", "Hijuelas", "La Cruz", "Nogales", "San Antonio", "Algarrobo", "El Quisco", "El Tabo", "Cartagena", "Santo Domingo", "San Felipe", "Catemu", "Llay-Llay", "Panquehue", "Putaendo", "Santa María", "Quilpué", "Limache", "Olmué", "Villa Alemana"], + "Región Metropolitana": ["Santiago", "Cerrillos", "Cerro Navia", "Conchalí", "El Bosque", "Estación Central", "Huechuraba", "Independencia", "La Cisterna", "La Florida", "La Granja", "La Pintana", "La Reina", "Las Condes", "Lo Barnechea", "Lo Espejo", "Lo Prado", "Macul", "Maipú", "Ñuñoa", "Pedro Aguirre Cerda", "Peñalolén", "Providencia", "Pudahuel", "Quilicura", "Quinta Normal", "Recoleta", "Renca", "San Joaquín", "San Miguel", "San Ramón", "Vitacura", "Puente Alto", "Pirque", "San José de Maipo", "San Bernardo", "Calera de Tango", "Buin", "Paine", "Melipilla", "Alhué", "Curacaví", "María Pinto", "San Pedro", "Talagante", "El Monte", "Isla de Maipo", "Padre Hurtado", "Peñaflor", "Colina", "Lampa", "Tiltil"], + "O'Higgins": ["Rancagua", "Codegua", "Coinco", "Coltauco", "Doñihue", "Graneros", "Las Cabras", "Machalí", "Malloa", "Mostazal", "Olivar", "Peumo", "Pichidegua", "Quinta de Tilcoco", "Rengo", "Requínoa", "San Vicente", "Pichilemu", "La Estrella", "Litueche", "Marchihue", "Navidad", "Paredones", "San Fernando", "Chépica", "Chimbarongo", "Lolol", "Nancagua", "Palmilla", "Peralillo", "Placilla", "Pumanque", "Santa Cruz"], + "Maule": ["Talca", "Constitución", "Curepto", "Empedrado", "Maule", "Pelarco", "Pencahue", "Río Claro", "San Clemente", "San Rafael", "Cauquenes", "Chanco", "Pelluhue", "Curicó", "Hualañé", "Licantén", "Molina", "Rauco", "Romeral", "Sagrada Familia", "Teno", "Vichuquén", "Linares", "Colbún", "Longaví", "Parral", "Retiro", "San Javier", "Villa Alegre", "Yerbas Buenas"], + "Ñuble": ["Chillán", "Bulnes", "Cobquecura", "Coelemu", "Coihueco", "Chillán Viejo", "El Carmen", "Ninhue", "Ñiquén", "Pemuco", "Pinto", "Portezuelo", "Quillón", "Quirihue", "Ránquil", "San Carlos", "San Fabián", "San Ignacio", "San Nicolás", "Treguaco", "Yungay"], + "Biobío": ["Concepción", "Coronel", "Chiguayante", "Florida", "Hualpén", "Hualqui", "Lota", "Penco", "San Pedro de la Paz", "Santa Juana", "Talcahuano", "Tomé", "Lebu", "Arauco", "Cañete", "Contulmo", "Curanilahue", "Los Álamos", "Tirúa", "Los Ángeles", "Antuco", "Cabrero", "Laja", "Mulchén", "Nacimiento", "Negrete", "Quilaco", "Quilleco", "San Rosendo", "Santa Bárbara", "Tucapel", "Yumbel", "Alto Biobío"], + "Araucanía": ["Temuco", "Carahue", "Cholchol", "Cunco", "Curarrehue", "Freire", "Galvarino", "Gorbea", "Lautaro", "Loncoche", "Melipeuco", "Nueva Imperial", "Padre Las Casas", "Perquenco", "Pitrufquén", "Pucón", "Saavedra", "Teodoro Schmidt", "Toltén", "Vilcún", "Villarrica", "Angol", "Collipulli", "Curacautín", "Ercilla", "Lonquimay", "Los Sauces", "Lumaco", "Purén", "Renaico", "Traiguén", "Victoria"], + "Los Ríos": ["Valdivia", "Corral", "Lanco", "Los Lagos", "Máfil", "Mariquina", "Paillaco", "Panguipulli", "La Unión", "Futrono", "Lago Ranco", "Río Bueno"], + "Los Lagos": ["Puerto Montt", "Calbuco", "Cochamó", "Fresia", "Frutillar", "Los Muermos", "Llanquihue", "Maullín", "Puerto Varas", "Castro", "Ancud", "Chonchi", "Curaco de Vélez", "Dalcahue", "Puqueldón", "Queilén", "Quellón", "Quemchi", "Quinchao", "Osorno", "Puerto Octay", "Purranque", "Puyehue", "Río Negro", "San Juan de la Costa", "San Pablo", "Chaitén", "Futaleufú", "Hualaihué", "Palena"], + "Aysén": ["Coyhaique", "Lago Verde", "Aysén", "Cisnes", "Guaitecas", "Cochrane", "O'Higgins", "Tortel", "Chile Chico", "Río Ibáñez"], + "Magallanes": ["Punta Arenas", "Laguna Blanca", "Río Verde", "San Gregorio", "Cabo de Hornos", "Antártica", "Porvenir", "Primavera", "Timaukel", "Natales", "Torres del Paine"] +} + +# Generate Choices tuples for Django +REGION_CHOICES = [(r, r) for r in REGIONS_COMMUNES.keys()] + +# Flat list of all communes for validation +ALL_COMMUNES = sorted(list(set([c for communes in REGIONS_COMMUNES.values() for c in communes]))) +COMMUNE_CHOICES = [(c, c) for c in ALL_COMMUNES] diff --git a/expenses/forms.py b/expenses/forms.py index 599abf9..58eb77d 100644 --- a/expenses/forms.py +++ b/expenses/forms.py @@ -1,8 +1,12 @@ from django import forms from django.contrib.auth.models import User, Group -from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor +from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor, VehicleOwnershipEnum +from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES +class MultipleFileInput(forms.FileInput): + allow_multiple_selected = True + class ExpenseForm(forms.ModelForm): date = forms.DateField( widget=forms.DateInput(attrs={'type': 'date'}), @@ -16,9 +20,27 @@ class ExpenseForm(forms.ModelForm): ) receipt = forms.FileField( - label='Adjuntar Boleta/Factura', + label='Adjuntar Evidencia(s) (Boleta / Factura)', required=False, - widget=forms.FileInput(attrs={'accept': 'image/*,.pdf'}) + widget=MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'}) + ) + + region = forms.ChoiceField( + choices=[('', '--- Seleccione Región ---')] + REGION_CHOICES, + label='Región Operativa', + required=False + ) + + comuna = forms.ChoiceField( + choices=[('', '--- Seleccione Comuna ---')] + COMMUNE_CHOICES, + label='Comuna', + required=False + ) + + vehicle_ownership = forms.ChoiceField( + choices=[('', '--- Seleccione Modalidad ---')] + VehicleOwnershipEnum.choices, + label='Modalidad / Vehículo', + required=False ) tax_amount_clp = forms.DecimalField( @@ -36,7 +58,7 @@ class ExpenseForm(forms.ModelForm): class Meta: model = Expense fields = [ - 'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'date', + 'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date', 'description', 'vendor', 'region', 'comuna', 'vehicle_ownership' ] widgets = { diff --git a/expenses/migrations/0007_alter_expense_vehicle_ownership.py b/expenses/migrations/0007_alter_expense_vehicle_ownership.py new file mode 100644 index 0000000..cb1491a --- /dev/null +++ b/expenses/migrations/0007_alter_expense_vehicle_ownership.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-08-20 23:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0006_project_category_userprofile_specialties_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='vehicle_ownership', + field=models.CharField(blank=True, choices=[('ARRENDADO', 'Arrendado (4x4, etc.)'), ('PROPIO', 'Propio (Reembolso Kilometraje)'), ('BUS', 'Bus'), ('UBER_TAXI', 'Uber/Taxi')], max_length=50, null=True, verbose_name='Modalidad / Vehículo'), + ), + ] diff --git a/expenses/models.py b/expenses/models.py index 8e163f0..46c571c 100644 --- a/expenses/models.py +++ b/expenses/models.py @@ -27,6 +27,12 @@ class ProjectStatusEnum(models.TextChoices): PAUSADA = 'PAUSADA', 'En Pausa' TERMINADO = 'TERMINADO', 'Terminado' +class VehicleOwnershipEnum(models.TextChoices): + ARRENDADO = 'ARRENDADO', 'Arrendado (4x4, etc.)' + PROPIO = 'PROPIO', 'Propio (Reembolso Kilometraje)' + BUS = 'BUS', 'Bus' + UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi' + class Project(models.Model): # Added core project/costcenter fields mapped from Prisma code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000') @@ -115,7 +121,7 @@ class Expense(models.Model): # Geographic & Logistic fields (Operaciones en terreno) region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)") comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna") - vehicle_ownership = models.CharField(max_length=100, blank=True, null=True, verbose_name="Modalidad / Vehículo") + vehicle_ownership = models.CharField(max_length=50, blank=True, null=True, choices=VehicleOwnershipEnum.choices, verbose_name="Modalidad / Vehículo") # 3-step Approval Flow Tracking approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación") diff --git a/expenses/urls.py b/expenses/urls.py index 3af625c..77de5c0 100644 --- a/expenses/urls.py +++ b/expenses/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('gastos/nuevo/', views.expense_create, name='expense_create'), path('gastos//editar/', views.expense_edit, name='expense_edit'), path('gastos//eliminar/', views.expense_delete, name='expense_delete'), + path('gastos/documento//eliminar/', views.delete_document, name='delete_document'), # Flujo de aprobación y Cuentas por Pagar path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'), diff --git a/expenses/views.py b/expenses/views.py index 86ac4c6..29e7257 100644 --- a/expenses/views.py +++ b/expenses/views.py @@ -9,11 +9,13 @@ 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 +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: @@ -126,10 +128,11 @@ def expense_create(request): expense.save() - receipt = request.FILES.get('receipt') - if receipt: + receipts = request.FILES.getlist('receipt') + if receipts: from .models import Document - Document.objects.create(expense=expense, file=receipt, title=receipt.name, uploaded_by=request.user) + for f in receipts: + Document.objects.create(expense=expense, file=f, title=f.name, uploaded_by=request.user) AuditLog.objects.create( user=request.user, @@ -142,7 +145,12 @@ def expense_create(request): return redirect('expense_list') else: form = ExpenseForm() - return render(request, 'expenses/expense_form.html', {'form': form}) + + context = { + 'form': form, + 'regions_communes_json': json.dumps(REGIONS_COMMUNES) + } + return render(request, 'expenses/expense_form.html', context) @login_required @@ -172,11 +180,11 @@ def expense_edit(request, pk): exp.save() - receipt = request.FILES.get('receipt') - if receipt: + receipts = request.FILES.getlist('receipt') + if receipts: from .models import Document - # Optionally delete old receipt: Document.objects.filter(expense=exp).delete() - Document.objects.create(expense=exp, file=receipt, title=receipt.name, uploaded_by=request.user) + for f in receipts: + Document.objects.create(expense=exp, file=f, title=f.name, uploaded_by=request.user) AuditLog.objects.create( user=request.user, @@ -192,7 +200,12 @@ def expense_edit(request, pk): return redirect('expense_list') else: form = ExpenseForm(instance=expense) - return render(request, 'expenses/expense_form.html', {'form': form}) + + context = { + 'form': form, + 'regions_communes_json': json.dumps(REGIONS_COMMUNES) + } + return render(request, 'expenses/expense_form.html', context) @login_required @@ -592,3 +605,30 @@ def category_delete(request, pk): 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) diff --git a/seed_projects.py b/seed_projects.py index e705bc0..135685b 100644 --- a/seed_projects.py +++ b/seed_projects.py @@ -14,9 +14,27 @@ def run_seed(): # This might cascade to expenses, which is expected for seeding a clean MVP/dev environment Project.objects.all().delete() - print("Seeding Portfolios and Projects...") + print("Seeding Root Cost Centers, Portfolios and Projects...") - # Portfolio definitions: name and number of projects + # 1. Create Root Cost Centers (Level 1) + cc_idi = Project.objects.create( + code="CC-IDI", + name="Centro de Costo I+D+i", + description="Centro de costo principal para Investigación, Desarrollo e Innovación tecnológica.", + assigned_budget_clp=350000000.00, + parent=None + ) + cc_adf = Project.objects.create( + code="CC-ADF", + name="Centro de Costo Administración y Finanzas", + description="Centro de costo corporativo para Administración, Finanzas y Overhead general.", + assigned_budget_clp=150000000.00, + parent=None + ) + print(f"Created Root Cost Center: {cc_idi.code} - {cc_idi.name}") + print(f"Created Root Cost Center: {cc_adf.code} - {cc_adf.name}") + + # 2. Portfolio definitions (Level 2) - under CC-IDI portfolio_def = { "Portafolio 1": 5, "Portafolio 2": 4, @@ -30,29 +48,56 @@ def run_seed(): for index, (port_name, num_projects) in enumerate(portfolio_def.items(), 1): port_code = f"PORT-{index:03d}" - # Create portfolio (parent) + # Create portfolio under CC-IDI portfolio = Project.objects.create( code=port_code, name=port_name, - description=f"Portafolio principal para agrupar {num_projects} proyectos contables.", - assigned_budget_clp=50000000.00, # 50M base portfolio budget - parent=None + description=f"Portafolio tecnológico para agrupar {num_projects} proyectos.", + assigned_budget_clp=50000000.00, + parent=cc_idi ) - print(f"Created Portfolio: {portfolio.code} - {portfolio.name}") + print(f" Created Portfolio: {portfolio.code} - {portfolio.name} (under CC-IDI)") - # Create subprojects + # Create subprojects (Level 3) for sub_idx in range(1, num_projects + 1): sub_code = f"PRY-{index}{sub_idx:02d}" subproject = Project.objects.create( code=sub_code, name=f"Proyecto {sub_idx} ({port_name})", description=f"Subproyecto contable imputable de {port_name}.", - assigned_budget_clp=10000000.00, # 10M base subproject budget + assigned_budget_clp=10000000.00, parent=portfolio ) - print(f" Created Project: {subproject.code} - {subproject.name}") + print(f" Created Project: {subproject.code} - {subproject.name}") - print("✅ Portfolios and Projects seeding completed successfully.") + # 3. Create Portfolios and Projects under CC-ADF + port_adm = Project.objects.create( + code="PORT-008", + name="Operaciones y Finanzas", + description="Portafolio administrativo para gastos fijos, soporte y legal.", + assigned_budget_clp=80000000.00, + parent=cc_adf + ) + print(f" Created Portfolio: {port_adm.code} - {port_adm.name} (under CC-ADF)") + + pry_adm = Project.objects.create( + code="PRY-801", + name="Sueldos y Overhead Administrativo", + description="Gasto operacional fijo de oficina, contabilidad y sueldos staff core.", + assigned_budget_clp=40000000.00, + parent=port_adm + ) + pry_fin = Project.objects.create( + code="PRY-802", + name="Servicios Financieros y Legal", + description="Gastos de auditoría externa, patentes y asesoría legal B2B.", + assigned_budget_clp=40000000.00, + parent=port_adm + ) + print(f" Created Project: {pry_adm.code} - {pry_adm.name}") + print(f" Created Project: {pry_fin.code} - {pry_fin.name}") + + print("✅ Root Cost Centers, Portfolios and Projects seeding completed successfully.") if __name__ == "__main__": run_seed() diff --git a/templates/expenses/expense_form.html b/templates/expenses/expense_form.html index 99580ab..55ebea4 100644 --- a/templates/expenses/expense_form.html +++ b/templates/expenses/expense_form.html @@ -72,6 +72,12 @@ {{ form.currency }} {% if form.currency.errors %}
{{ form.currency.errors }}
{% endif %} + +
+ + {{ form.responsible_person }} + {% if form.responsible_person.errors %}
{{ form.responsible_person.errors }}
{% endif %} +
@@ -94,15 +100,29 @@ {% if form.description.errors %}
{{ form.description.errors }}
{% endif %}
-
- +
+ {{ form.receipt }} - Formatos aceptados: Imágenes (.png, .jpg) o Documentos PDF. +
+ Puedes seleccionar múltiples archivos (imágenes o PDF). {% if form.receipt.errors %}
{{ form.receipt.errors }}
{% endif %} - {% if form.instance.pk and form.instance.documents.first %} -
- Evidencia Actual: Ver Archivo Adjunto + {% if form.instance.pk and form.instance.documents.all %} +
+ Documentos Adjuntos Actuales: +
+ {% for doc in form.instance.documents.all %} + + {% endfor %} +
{% endif %}
@@ -134,8 +154,13 @@
+ + + {% endblock %}