This commit is contained in:
Nachou-u
2026-08-20 19:43:56 -04:00
parent 10b2270318
commit 08a26cbf2f
8 changed files with 413 additions and 80 deletions
+27
View File
@@ -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]
+26 -4
View File
@@ -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 = {
@@ -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'),
),
]
+7 -1
View File
@@ -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")
+1
View File
@@ -10,6 +10,7 @@ urlpatterns = [
path('gastos/nuevo/', views.expense_create, name='expense_create'),
path('gastos/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
path('gastos/documento/<int:pk>/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'),
+50 -10
View File
@@ -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)