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
+55
View File
@@ -0,0 +1,55 @@
from ninja import Router
from pydantic import BaseModel
from typing import List, Optional
from decimal import Decimal
from .services.sii import SIIService
from .models import Project
router = Router()
class RUTValidationResponse(BaseModel):
is_valid: bool
formatted_rut: str
error_message: Optional[str] = None
@router.get("/sii/validate-rut", response=RUTValidationResponse)
def validate_rut(request, rut: str):
"""
Valida un RUT chileno usando la lógica del SIIService.
"""
is_valid, result = SIIService.validate_rut(rut)
if is_valid:
return RUTValidationResponse(is_valid=True, formatted_rut=result)
else:
return RUTValidationResponse(is_valid=False, formatted_rut="", error_message=result)
class ProjectOut(BaseModel):
id: int
code: str
name: str
@router.get("/projects/", response=List[ProjectOut])
def get_projects(request):
"""
Obtiene la lista de proyectos reales (excluye Portafolios y Centros de Costo).
"""
# Proyectos reales: parent__parent__isnull=False
qs = Project.objects.filter(parent__parent__isnull=False, status='Activo').order_by('name')
return [ProjectOut(id=p.id, code=p.code, name=p.name) for p in qs]
class TaxCalculationResponse(BaseModel):
net_amount: float
tax_amount: float
total_amount: float
@router.get("/sii/calculate-tax", response=TaxCalculationResponse)
def calculate_tax(request, amount: float, tax_type: str):
"""
Calcula el impuesto y el total basado en el tipo de documento.
"""
res = SIIService.calculate_taxes(amount, tax_type)
return TaxCalculationResponse(
net_amount=float(res['net_amount']),
tax_amount=float(res['tax_amount']),
total_amount=float(res['total_amount'])
)
+223 -18
View File
@@ -1,9 +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, VehicleOwnershipEnum
from .models import (
Expense, Project, Category, SystemSetting, UserProfile, Document,
CurrencyEnum, ExpenseTypeEnum, Vendor, VehicleOwnershipEnum,
ExpenseReport, VehicleLog, ExpenseReportStatusEnum
)
from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
from .services.sii import SIIService
from decimal import Decimal
import os
@@ -29,10 +32,23 @@ class ExpenseForm(forms.ModelForm):
label='Fecha del Gasto'
)
is_reimbursement = forms.BooleanField(
label='¿Es Rendición / Reembolso de Gastos?',
required=False,
initial=False,
widget=forms.CheckboxInput(attrs={'id': 'id_is_reimbursement', 'x-model': 'isReimbursement'})
)
amount_clp = forms.CharField(
label='Monto Neto (CLP)',
required=True,
widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000'})
widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000', 'id': 'id_amount_clp', 'x-model': 'amountClp', '@input': 'formatAmount(); calculateTax()'})
)
document_number = forms.CharField(
label='N° Folio Boleta / Factura (DTE)',
required=False,
widget=forms.TextInput(attrs={'placeholder': 'Ej: 10482', 'id': 'id_document_number'})
)
receipt = MultipleFileField(
@@ -61,19 +77,19 @@ class ExpenseForm(forms.ModelForm):
tax_amount_clp = forms.CharField(
label='Monto Impuesto (CLP)',
required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
widget=forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_tax_amount_clp', ':value': 'taxAmountClp'})
)
total_amount_clp = forms.CharField(
label='Monto Total (CLP)',
required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
widget=forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_total_amount_clp', ':value': 'totalAmountClp'})
)
new_vendor_rut = forms.CharField(
label="RUT Nuevo Proveedor",
required=False,
widget=forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut'})
widget=forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut', 'x-model': 'newVendorRut', '@blur': 'validateRut()', ':class': "rutError ? 'border-red-500' : ''"})
)
new_vendor_name = forms.CharField(
@@ -82,16 +98,49 @@ class ExpenseForm(forms.ModelForm):
widget=forms.TextInput(attrs={'placeholder': 'Ej: IMPORTADORA SPA', 'id': 'id_new_vendor_name'})
)
# Conditional Vehicle Log Fields
v_license_plate = forms.CharField(label="Patente Vehículo", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: ABCD-12'}))
v_driver_name = forms.CharField(label="Nombre Conductor / Responsable", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: Juan Pérez'}))
v_odometer_start = forms.DecimalField(label="Odómetro Inicial (KM)", required=False, initial=Decimal('0'), widget=forms.NumberInput(attrs={'step': '1'}))
v_odometer_end = forms.DecimalField(label="Odómetro Final (KM)", required=False, initial=Decimal('0'), widget=forms.NumberInput(attrs={'step': '1'}))
v_fuel_type = forms.CharField(label="Tipo Combustible", required=False, initial="Diésel", widget=forms.TextInput(attrs={'placeholder': 'Ej: Diésel / 95 Octanos'}))
v_route_origin = forms.CharField(label="Origen", required=False, initial="Base EFINTEL", widget=forms.TextInput())
v_route_destination = forms.CharField(label="Destino / Faena", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: Faena Minera Spence'}))
class Meta:
model = Expense
fields = [
'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date',
'project', 'category', 'is_reimbursement', 'report', 'document_number', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date',
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}),
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno', 'required': 'required'}),
'is_reimbursement': forms.CheckboxInput(attrs={'id': 'id_is_reimbursement', 'x-model': 'isReimbursement'}),
'amount_clp': forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000', 'id': 'id_amount_clp', 'x-model': 'amountClp', '@input': 'formatAmount(); calculateTax()', 'required': 'required', 'pattern': '^[0-9.]+$', 'title': 'Ingrese solo números y puntos'}),
'tax_type': forms.Select(attrs={'x-model': 'taxType', '@change': 'calculateTax()', 'required': 'required'}),
'tax_amount_clp': forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_tax_amount_clp', ':value': 'taxAmountClp'}),
'total_amount_clp': forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_total_amount_clp', ':value': 'totalAmountClp'}),
'new_vendor_rut': forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut', 'x-model': 'newVendorRut', '@blur': 'validateRut()', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}),
'region': forms.Select(attrs={'x-model': 'selectedRegion', '@change': 'updateComunas()'}),
'comuna': forms.Select(attrs={'x-model': 'selectedComuna'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False)
if self.instance and self.instance.pk:
try:
vlog = self.instance.vehicle_log
self.fields['v_license_plate'].initial = vlog.license_plate
self.fields['v_driver_name'].initial = vlog.driver_name
self.fields['v_odometer_start'].initial = vlog.odometer_start
self.fields['v_odometer_end'].initial = vlog.odometer_end
self.fields['v_fuel_type'].initial = vlog.fuel_type
self.fields['v_route_origin'].initial = vlog.route_origin
self.fields['v_route_destination'].initial = vlog.route_destination
except Exception:
pass
def clean_amount_clp(self):
data = self.cleaned_data.get('amount_clp', '')
if not data:
@@ -132,11 +181,60 @@ class ExpenseForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
is_reimb = cleaned_data.get('is_reimbursement', False)
resp_person = cleaned_data.get('responsible_person')
project = cleaned_data.get('project')
# Requerimiento 7: Lógica de Rendición vs Centro I+D+i
if is_reimb:
if not resp_person:
raise forms.ValidationError("Si el gasto es una rendición, debe seleccionar el responsable de la rendición.")
else:
# Si NO es rendición, pasa al centro de costos I+D+i
if not project:
idi_proj = Project.objects.filter(models.Q(code__icontains='IDI') | models.Q(name__icontains='I+D+i') | models.Q(type=ExpenseTypeEnum.COSTO_DIRECTO)).first()
if idi_proj:
cleaned_data['project'] = idi_proj
new_name = cleaned_data.get('new_vendor_name')
new_rut = cleaned_data.get('new_vendor_rut')
if new_rut:
is_valid, res = SIIService.validate_rut(new_rut)
if not is_valid:
raise forms.ValidationError(f"RUT de proveedor inválido: {res}")
cleaned_data['new_vendor_rut'] = res
if (new_name and not new_rut) or (new_rut and not new_name):
raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.")
# Recalculate taxes via SIIService
amount = cleaned_data.get('amount_clp')
tax_type = cleaned_data.get('tax_type')
if amount and tax_type:
tax_res = SIIService.calculate_taxes(amount, tax_type)
cleaned_data['tax_amount_clp'] = tax_res['tax_amount']
cleaned_data['total_amount_clp'] = tax_res['total_amount']
# Duplicate check alert
vendor = cleaned_data.get('vendor')
doc_num = cleaned_data.get('document_number')
date_val = cleaned_data.get('date')
if amount and (vendor or doc_num):
duplicates = SIIService.check_duplicate_expense(
expense_model=Expense,
vendor_id=vendor.id if vendor else None,
amount_clp=amount,
date_val=date_val,
document_number=doc_num,
exclude_id=self.instance.pk if self.instance else None
)
if duplicates:
dup_str = ", ".join([f"{d.expense_code} ({d.date}, ${d.amount_clp:,.0f})" for d in duplicates])
# We can allow with warning or raise ValidationError if identical document number
if doc_num and any(d.document_number == doc_num for d in duplicates):
raise forms.ValidationError(f"¡Alerta Anti-Fraude! Ya existe un gasto registrado con el mismo Folio/Documento N° {doc_num}: {dup_str}")
return cleaned_data
def save(self, commit=True):
@@ -144,7 +242,6 @@ class ExpenseForm(forms.ModelForm):
new_name = self.cleaned_data.get('new_vendor_name')
new_rut = self.cleaned_data.get('new_vendor_rut')
if new_name and new_rut:
from .models import Vendor
vendor, _ = Vendor.objects.get_or_create(
rut=new_rut.strip(),
defaults={'name': new_name.strip()}
@@ -152,15 +249,114 @@ class ExpenseForm(forms.ModelForm):
instance.vendor = vendor
if commit:
instance.save()
# Requerimiento 14: Save VehicleLog ONLY if combustible category
if instance.is_vehicle_category():
plate = self.cleaned_data.get('v_license_plate')
driver = self.cleaned_data.get('v_driver_name')
if plate or driver:
vlog, _ = VehicleLog.objects.get_or_create(
expense=instance,
defaults={
'project': instance.project,
'license_plate': plate or 'S/P',
'driver_name': driver or instance.user.username,
'odometer_start': self.cleaned_data.get('v_odometer_start') or Decimal('0'),
'odometer_end': self.cleaned_data.get('v_odometer_end') or Decimal('0'),
'fuel_type': self.cleaned_data.get('v_fuel_type') or 'Diésel',
'route_origin': self.cleaned_data.get('v_route_origin') or 'Base',
'route_destination': self.cleaned_data.get('v_route_destination') or 'Faena',
'log_date': instance.date,
}
)
if not _:
vlog.license_plate = plate or vlog.license_plate
vlog.driver_name = driver or vlog.driver_name
vlog.odometer_start = self.cleaned_data.get('v_odometer_start') or vlog.odometer_start
vlog.odometer_end = self.cleaned_data.get('v_odometer_end') or vlog.odometer_end
vlog.fuel_type = self.cleaned_data.get('v_fuel_type') or vlog.fuel_type
vlog.route_origin = self.cleaned_data.get('v_route_origin') or vlog.route_origin
vlog.route_destination = self.cleaned_data.get('v_route_destination') or vlog.route_destination
vlog.save()
return instance
class ExpenseReportForm(forms.ModelForm):
start_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Inicio")
end_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Término")
class Meta:
model = ExpenseReport
fields = ['title', 'project', 'start_date', 'end_date', 'purpose', 'notes']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Viaje Faena Antofagasta - Pruebas Piloto'}),
'project': forms.Select(attrs={'class': 'form-control'}),
'purpose': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Objetivos del viaje, comisiones en terreno o faena...'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Comentarios adicionales...'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False)
class VehicleLogForm(forms.ModelForm):
log_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Registro")
class Meta:
model = VehicleLog
fields = ['expense', 'project', 'license_plate', 'driver_name', 'driver_rut', 'odometer_start', 'odometer_end', 'fuel_type', 'fuel_liters', 'route_origin', 'route_destination', 'log_date', 'notes']
widgets = {
'expense': forms.Select(attrs={'class': 'form-control'}),
'project': forms.Select(attrs={'class': 'form-control'}),
'license_plate': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ABCD-12'}),
'driver_name': forms.TextInput(attrs={'class': 'form-control'}),
'driver_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '12.345.678-9'}),
'odometer_start': forms.NumberInput(attrs={'class': 'form-control', 'step': '1'}),
'odometer_end': forms.NumberInput(attrs={'class': 'form-control', 'step': '1'}),
'fuel_type': forms.TextInput(attrs={'class': 'form-control'}),
'fuel_liters': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'route_origin': forms.TextInput(attrs={'class': 'form-control'}),
'route_destination': forms.TextInput(attrs={'class': 'form-control'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False)
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['code', 'name', 'parent', 'category', 'status', 'type', 'description', 'manager', 'assigned_budget_clp']
fields = [
'code', 'name', 'parent', 'category', 'status', 'type',
'manager', 'assigned_budget_clp', 'assigned_budget_usd',
'payment_method', 'total_income_clp', 'description'
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
'code': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}),
'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}),
'assigned_budget_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'assigned_budget_usd': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'step': '0.01'}),
'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}),
}
class ProjectPhaseForm(forms.ModelForm):
due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada")
class Meta:
from .models import ProjectPhase
model = ProjectPhase
fields = ['phase_number', 'name', 'planned_amount_clp', 'income_amount_clp', 'expense_amount_clp', 'due_date', 'is_completed', 'description']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Hito 1 - Entrega Prototipo', 'required': 'required'}),
'description': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
'planned_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'income_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
'expense_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}),
}
@@ -177,12 +373,26 @@ class VendorForm(forms.ModelForm):
class Meta:
model = Vendor
fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms']
widgets = {
'rut': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}),
'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
}
def clean_rut(self):
rut = self.cleaned_data.get('rut')
if rut:
is_valid, formatted_or_err = SIIService.validate_rut(rut)
if not is_valid:
raise forms.ValidationError(formatted_or_err)
return formatted_or_err
return rut
class SystemSettingForm(forms.ModelForm):
class Meta:
model = SystemSetting
fields = ['global_budget', 'usd_exchange_rate']
fields = ['iva_rate', 'usd_exchange_rate']
class ResponsableForm(forms.Form):
@@ -208,11 +418,9 @@ class ResponsableForm(forms.Form):
if self.instance:
self.fields['username'].initial = self.instance.username
self.fields['email'].initial = self.instance.email
# Set role
groups = self.instance.groups.all()
if groups.exists():
self.fields['role'].initial = groups.first()
# Set profile info
try:
self.fields['description'].initial = self.instance.profile.description
self.fields['rut'].initial = self.instance.profile.rut
@@ -220,7 +428,6 @@ class ResponsableForm(forms.Form):
self.fields['specialties'].initial = self.instance.profile.specialties.all()
except Exception:
pass
# Password is optional for edit
self.fields['password'].required = False
self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla."
@@ -259,7 +466,6 @@ class ResponsableForm(forms.Form):
user.is_superuser = False
user.save()
user.groups.clear()
user.groups.add(role)
@@ -269,7 +475,6 @@ class ResponsableForm(forms.Form):
profile.job_title = job_title
profile.save()
# Save specialties
specialties = self.cleaned_data.get('specialties')
if specialties is not None:
profile.specialties.set(specialties)
@@ -0,0 +1,76 @@
# Generated for EFINTEL ERP Enhancements
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('expenses', '0007_alter_expense_vehicle_ownership'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ExpenseReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('report_code', models.CharField(default='EXP-REP-001', max_length=50, unique=True, verbose_name='Código Rendición')),
('title', models.CharField(max_length=250, verbose_name='Título del Informe / Viaje')),
('start_date', models.DateField(verbose_name='Fecha de Inicio')),
('end_date', models.DateField(verbose_name='Fecha de Término')),
('purpose', models.TextField(verbose_name='Propósito / Justificación de la Comisión')),
('status', models.CharField(choices=[('BORRADOR', 'Borrador'), ('ENVIADO', 'Enviado para Aprobación'), ('APROBADO', 'Aprobado'), ('REEMBOLSADO', 'Reembolsado / Pagado'), ('OBSERVADO', 'Observado / Rechazado')], default='BORRADOR', max_length=30, verbose_name='Estado Rendición')),
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Aprobación')),
('notes', models.TextField(blank=True, verbose_name='Observaciones / Comentarios de Cierre')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha Creación')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Última Modificación')),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_expense_reports', to=settings.AUTH_USER_MODEL, verbose_name='Aprobado por')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expense_reports', to='expenses.project', verbose_name='Proyecto Imputado')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expense_reports', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Rendidor')),
],
options={
'verbose_name': 'Informe de Rendición de Gastos',
'verbose_name_plural': 'Informes de Rendición de Gastos',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='expense',
name='document_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='N° Folio Boleta / Factura'),
),
migrations.AddField(
model_name='expense',
name='report',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.expensereport', verbose_name='Informe de Rendición (Opcional)'),
),
migrations.CreateModel(
name='VehicleLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('license_plate', models.CharField(max_length=20, verbose_name='Patente Vehículo')),
('driver_name', models.CharField(max_length=150, verbose_name='Nombre Conductor')),
('driver_rut', models.CharField(blank=True, max_length=20, verbose_name='RUT Conductor')),
('odometer_start', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='Odómetro Inicial (KM)')),
('odometer_end', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='Odómetro Final (KM)')),
('km_traveled', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='KM Recorridos')),
('fuel_type', models.CharField(blank=True, default='Diésel', max_length=50, verbose_name='Tipo Combustible')),
('fuel_liters', models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=8, verbose_name='Litros Cargados')),
('route_origin', models.CharField(default='Oficina / Base', max_length=150, verbose_name='Origen')),
('route_destination', models.CharField(max_length=150, verbose_name='Destino / Faena')),
('log_date', models.DateField(verbose_name='Fecha de Registro')),
('notes', models.TextField(blank=True, verbose_name='Observaciones de Ruta')),
('created_at', models.DateTimeField(auto_now_add=True)),
('expense', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vehicle_log', to='expenses.expense', verbose_name='Gasto Asociado')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vehicle_logs', to='expenses.project', verbose_name='Proyecto')),
],
options={
'verbose_name': 'Bitácora de Vehículo',
'verbose_name_plural': 'Bitácoras de Vehículos',
'ordering': ['-log_date'],
},
),
]
@@ -0,0 +1,73 @@
# Generated by Django 6.1 on 2026-09-07 02:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0008_expensereport_document_number_vehiclelog'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='expense',
name='is_reimbursement',
field=models.BooleanField(default=False, verbose_name='¿Es Rendición / Reembolso de Gastos?'),
),
migrations.AddField(
model_name='project',
name='payment_method',
field=models.CharField(default='Contrato por Fases / Hitos', max_length=150, verbose_name='Forma de Pago'),
),
migrations.AddField(
model_name='project',
name='total_income_clp',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Ingresos Totales (CLP)'),
),
migrations.AddField(
model_name='systemsetting',
name='iva_rate',
field=models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='Tasa de IVA (% Dinámico SII)'),
),
migrations.AlterField(
model_name='expense',
name='responsible_person',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_responsible', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Solicitante / Rendidor'),
),
migrations.AlterField(
model_name='project',
name='manager',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_projects', to=settings.AUTH_USER_MODEL, verbose_name='Encargado / Director'),
),
migrations.AlterField(
model_name='project',
name='status',
field=models.CharField(choices=[('BORRADOR', 'Borrador / En Diseño'), ('ACTIVO', 'Activo'), ('PAUSADA', 'En Pausa'), ('TERMINADO', 'Terminado')], default='ACTIVO', max_length=50, verbose_name='Estado de Proyecto'),
),
migrations.CreateModel(
name='ProjectPhase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phase_number', models.IntegerField(default=1, verbose_name='N° Fase / Hito')),
('name', models.CharField(max_length=150, verbose_name='Nombre de la Fase / Hito')),
('description', models.TextField(blank=True, verbose_name='Descripción de Entregables')),
('planned_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Planificado / Pactado (CLP)')),
('income_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Ingresado / Cobrado (CLP)')),
('expense_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Egresado / Gastado (CLP)')),
('due_date', models.DateField(blank=True, null=True, verbose_name='Fecha Pactada')),
('is_completed', models.BooleanField(default=False, verbose_name='Fase Completada')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phases', to='expenses.project', verbose_name='Proyecto')),
],
options={
'verbose_name': 'Fase / Hito de Pago de Proyecto',
'verbose_name_plural': 'Fases / Hitos de Pago de Proyecto',
'ordering': ['phase_number'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-07 02:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0009_expense_is_reimbursement_project_payment_method_and_more'),
]
operations = [
migrations.AlterField(
model_name='expense',
name='amount_usd',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Total (USD)'),
),
]
@@ -0,0 +1,88 @@
# Generated by Django 6.1 on 2026-09-07 15:20
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0010_alter_expense_amount_usd'),
]
operations = [
migrations.CreateModel(
name='Reimbursement',
fields=[
],
options={
'verbose_name': 'Rendición Especializada',
'verbose_name_plural': 'Rendiciones Especializadas',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('expenses.expense',),
),
migrations.AddField(
model_name='category',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Fecha de Creación'),
preserve_default=False,
),
migrations.AddField(
model_name='category',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'),
),
migrations.AlterField(
model_name='category',
name='code',
field=models.CharField(max_length=50, unique=True, verbose_name='Código'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=200, verbose_name='Nombre'),
),
migrations.AlterField(
model_name='expense',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'),
),
migrations.AlterField(
model_name='expense',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'),
),
migrations.AlterField(
model_name='project',
name='code',
field=models.CharField(max_length=50, unique=True, verbose_name='Código'),
),
migrations.AlterField(
model_name='project',
name='name',
field=models.CharField(max_length=200, verbose_name='Nombre'),
),
migrations.AlterField(
model_name='projectphase',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'),
),
migrations.AlterField(
model_name='projectphase',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'),
),
migrations.AlterField(
model_name='vendor',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'),
),
migrations.AlterField(
model_name='vendor',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'),
),
]
+223 -24
View File
@@ -1,5 +1,6 @@
from django.db import models
from django.contrib.auth.models import User
from decimal import Decimal
import uuid
# Enums
@@ -23,6 +24,7 @@ class ApprovalStatusEnum(models.TextChoices):
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
class ProjectStatusEnum(models.TextChoices):
BORRADOR = 'BORRADOR', 'Borrador / En Diseño'
ACTIVO = 'ACTIVO', 'Activo'
PAUSADA = 'PAUSADA', 'En Pausa'
TERMINADO = 'TERMINADO', 'Terminado'
@@ -33,38 +35,95 @@ class VehicleOwnershipEnum(models.TextChoices):
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')
name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro")
class ExpenseReportStatusEnum(models.TextChoices):
BORRADOR = 'BORRADOR', 'Borrador'
ENVIADO = 'ENVIADO', 'Enviado para Aprobación'
APROBADO = 'APROBADO', 'Aprobado'
REEMBOLSADO = 'REEMBOLSADO', 'Reembolsado / Pagado'
OBSERVADO = 'OBSERVADO', 'Observado / Rechazado'
from django.core.exceptions import ValidationError
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
class Meta:
abstract = True
class CatalogModel(TimeStampedModel):
code = models.CharField(max_length=50, unique=True, verbose_name="Código")
name = models.CharField(max_length=200, verbose_name="Nombre")
description = models.TextField(blank=True, verbose_name="Descripción")
class Meta:
abstract = True
class Project(CatalogModel):
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado")
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado / Director")
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Portafolio / Proyecto Padre")
assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)")
assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)")
actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)")
payment_method = models.CharField(max_length=150, default="Contrato por Fases / Hitos", verbose_name="Forma de Pago")
total_income_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Ingresos Totales (CLP)")
type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro")
status = models.CharField(max_length=50, choices=ProjectStatusEnum.choices, default=ProjectStatusEnum.ACTIVO, verbose_name="Estado de Proyecto")
category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name="Categoría del Proyecto")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_projects', verbose_name="Creado por")
def __str__(self):
return f"{self.code} - {self.name}"
@property
def total_expenses_clp(self):
return self.expenses.aggregate(total=models.Sum('amount_clp'))['total'] or Decimal('0.00')
@property
def balance_clp(self):
return self.total_income_clp - self.total_expenses_clp
@property
def budget_balance_clp(self):
return self.assigned_budget_clp - self.total_expenses_clp
class Meta:
verbose_name = "Proyecto / Centro de Costo"
verbose_name_plural = "Proyectos y Centros"
class Category(models.Model):
code = models.CharField(max_length=10, unique=True, verbose_name="Código de Categoría")
name = models.CharField(max_length=100, verbose_name="Nombre de Categoría")
description = models.TextField(blank=True, verbose_name="Descripción")
class ProjectPhase(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='phases', verbose_name="Proyecto")
phase_number = models.IntegerField(default=1, verbose_name="N° Fase / Hito")
name = models.CharField(max_length=150, verbose_name="Nombre de la Fase / Hito")
description = models.TextField(blank=True, verbose_name="Descripción de Entregables")
planned_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Planificado / Pactado (CLP)")
income_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Ingresado / Cobrado (CLP)")
expense_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Egresado / Gastado (CLP)")
due_date = models.DateField(null=True, blank=True, verbose_name="Fecha Pactada")
is_completed = models.BooleanField(default=False, verbose_name="Fase Completada")
class Meta:
ordering = ['phase_number']
verbose_name = "Fase / Hito de Pago de Proyecto"
verbose_name_plural = "Fases / Hitos de Pago de Proyecto"
@property
def balance_clp(self):
return self.income_amount_clp - self.expense_amount_clp
def __str__(self):
return f"{self.project.code} - Fase {self.phase_number}: {self.name}"
class Category(CatalogModel):
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Categoría Padre")
def __str__(self):
@@ -74,12 +133,12 @@ class Category(models.Model):
verbose_name = "Categoría"
verbose_name_plural = "Categorías"
class Vendor(models.Model):
class Vendor(TimeStampedModel):
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre")
business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial")
# Banking details
bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco")
account_type = models.CharField(max_length=50, blank=True, null=True, verbose_name="Tipo de Cuenta")
account_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Número de Cuenta")
@@ -87,8 +146,6 @@ class Vendor(models.Model):
payment_terms = models.IntegerField(default=30, verbose_name="Plazo de Pago (Días)")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.rut} - {self.name}"
@@ -98,18 +155,61 @@ class Vendor(models.Model):
verbose_name_plural = "Proveedores"
ordering = ['name']
class Expense(models.Model):
class ExpenseReport(models.Model):
report_code = models.CharField(max_length=50, unique=True, verbose_name="Código Rendición", default="EXP-REP-001")
title = models.CharField(max_length=250, verbose_name="Título del Informe / Viaje")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Responsable Rendidor")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Proyecto Imputado")
start_date = models.DateField(verbose_name="Fecha de Inicio")
end_date = models.DateField(verbose_name="Fecha de Término")
purpose = models.TextField(verbose_name="Propósito / Justificación de la Comisión")
status = models.CharField(max_length=30, choices=ExpenseReportStatusEnum.choices, default=ExpenseReportStatusEnum.BORRADOR, verbose_name="Estado Rendición")
approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_expense_reports', verbose_name="Aprobado por")
approved_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Aprobación")
notes = models.TextField(blank=True, verbose_name="Observaciones / Comentarios de Cierre")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha Creación")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
def __str__(self):
return f"{self.report_code} - {self.title} ({self.user.username})"
@property
def total_amount(self):
return sum(exp.total_amount_clp for exp in self.expenses.all())
@property
def total_neto(self):
return sum(exp.amount_clp for exp in self.expenses.all())
@property
def expenses_count(self):
return self.expenses.count()
class Meta:
verbose_name = "Informe de Rendición de Gastos"
verbose_name_plural = "Informes de Rendición de Gastos"
ordering = ['-created_at']
class Expense(TimeStampedModel):
expense_code = models.CharField(max_length=50, unique=True, default=uuid.uuid4, verbose_name="Código Transacción")
report = models.ForeignKey(ExpenseReport, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Informe de Rendición (Opcional)")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expenses', verbose_name="Proyecto")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Subcategoría")
# Financial fields
document_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="N° Folio Boleta / Factura")
amount_clp = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (CLP)")
tax_type = models.CharField(max_length=20, choices=TaxTypeEnum.choices, default=TaxTypeEnum.IVA_19, verbose_name="Tipo de Impuesto")
tax_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Impuesto (CLP)")
total_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (CLP)")
amount_usd = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Total (USD)")
amount_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (USD)")
currency = models.CharField(max_length=10, choices=CurrencyEnum.choices, default=CurrencyEnum.CLP, verbose_name="Moneda Original")
exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tipo de Cambio")
@@ -118,33 +218,110 @@ class Expense(models.Model):
vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Proveedor / Emisor")
# 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=50, blank=True, null=True, choices=VehicleOwnershipEnum.choices, verbose_name="Modalidad / Vehículo")
is_reimbursement = models.BooleanField(default=False, verbose_name="¿Es Rendición / Reembolso de Gastos?")
# 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")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por")
responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante")
responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante / Rendidor")
elevated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_elevated', verbose_name="Elevado por (Paso 2)")
paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_paid', verbose_name="Pagado por (Paso 3)")
rejection_reason = models.TextField(blank=True, null=True, verbose_name="Motivo de Observación/Rechazo")
paid_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Pago")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
super().clean()
if self.amount_clp and self.amount_clp < 0:
raise ValidationError({'amount_clp': 'El monto neto no puede ser negativo.'})
amount = Decimal(str(self.amount_clp or '0'))
tax = Decimal(str(self.tax_amount_clp or '0'))
expected = amount + tax
if self.total_amount_clp and abs(self.total_amount_clp - expected) > Decimal('1.0'):
raise ValidationError({'total_amount_clp': f'Discrepancia. Total esperado: {expected}'})
def approve_expense(self, user):
if self.approval_status == ApprovalStatusEnum.EN_REVISION:
self.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
self.elevated_by = user
self.save()
def reject_expense(self, reason):
self.approval_status = ApprovalStatusEnum.OBSERVADO_RECHAZADO
self.rejection_reason = reason
self.save()
def save(self, *args, **kwargs):
if not self.amount_usd or self.amount_usd == Decimal('0'):
rate = Decimal(str(self.exchange_rate or '950.0'))
if self.amount_clp:
amount_dec = Decimal(str(self.amount_clp))
self.amount_usd = (amount_dec / rate).quantize(Decimal('0.01'))
if not self.total_amount_clp or self.total_amount_clp == Decimal('0'):
amount_clp = Decimal(str(self.amount_clp or '0'))
tax_clp = Decimal(str(self.tax_amount_clp or '0'))
self.total_amount_clp = amount_clp + tax_clp
super().save(*args, **kwargs)
def __str__(self):
return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})"
def is_vehicle_category(self):
if self.category:
return self.category.code == '3101' or 'combustible' in self.category.name.lower()
return False
class Meta:
verbose_name = "Gasto"
verbose_name_plural = "Gastos"
ordering = ['-date', '-created_at']
class VehicleLog(models.Model):
expense = models.OneToOneField(Expense, on_delete=models.CASCADE, related_name='vehicle_log', null=True, blank=True, verbose_name="Gasto Asociado")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='vehicle_logs', verbose_name="Proyecto")
license_plate = models.CharField(max_length=20, verbose_name="Patente Vehículo")
driver_name = models.CharField(max_length=150, verbose_name="Nombre Conductor")
driver_rut = models.CharField(max_length=20, blank=True, verbose_name="RUT Conductor")
odometer_start = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Inicial (KM)")
odometer_end = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Final (KM)")
km_traveled = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="KM Recorridos")
fuel_type = models.CharField(max_length=50, default="Diésel", verbose_name="Tipo Combustible", blank=True)
fuel_liters = models.DecimalField(max_digits=8, decimal_places=2, default=0, blank=True, verbose_name="Litros Cargados")
route_origin = models.CharField(max_length=150, verbose_name="Origen", default="Oficina / Base")
route_destination = models.CharField(max_length=150, verbose_name="Destino / Faena")
log_date = models.DateField(verbose_name="Fecha de Registro")
notes = models.TextField(blank=True, verbose_name="Observaciones de Ruta")
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
if self.odometer_end and self.odometer_start and self.odometer_end >= self.odometer_start:
self.km_traveled = self.odometer_end - self.odometer_start
super().save(*args, **kwargs)
def __str__(self):
return f"{self.license_plate} - {self.driver_name} ({self.km_traveled} KM)"
class Meta:
verbose_name = "Bitácora de Vehículo"
verbose_name_plural = "Bitácoras de Vehículos"
ordering = ['-log_date']
class Document(models.Model):
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado")
file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto")
@@ -156,10 +333,20 @@ class Document(models.Model):
def __str__(self):
return f"Evidencia para {self.expense.expense_code}"
@property
def is_image(self):
ext = self.file.name.lower().split('.')[-1] if '.' in self.file.name else ''
return ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']
@property
def is_pdf(self):
return self.file.name.lower().endswith('.pdf')
class Meta:
verbose_name = "Documento Evidencia"
verbose_name_plural = "Documentos Evidencias"
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario")
rut = models.CharField(max_length=20, blank=True, null=True, verbose_name="RUT")
@@ -177,6 +364,7 @@ class UserProfile(models.Model):
verbose_name = "Perfil de Usuario"
verbose_name_plural = "Perfiles de Usuario"
class AuditLog(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario")
action = models.CharField(max_length=100, verbose_name="Acción")
@@ -197,9 +385,11 @@ class AuditLog(models.Model):
verbose_name = "Log de Auditoría (SAP CDHDR/CDPOS)"
verbose_name_plural = "Logs de Auditoría"
class SystemSetting(models.Model):
global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i")
usd_exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tasa USD/CLP por Defecto")
iva_rate = models.DecimalField(max_digits=5, decimal_places=2, default=19.00, verbose_name="Tasa de IVA (% Dinámico SII)")
def __str__(self):
return "Configuración Global EFINTEL"
@@ -207,3 +397,12 @@ class SystemSetting(models.Model):
class Meta:
verbose_name = "Configuración del Sistema"
verbose_name_plural = "Configuraciones del Sistema"
class Reimbursement(Expense):
class Meta:
proxy = True
verbose_name = "Rendición Especializada"
verbose_name_plural = "Rendiciones Especializadas"
def get_approval_flow(self):
return "Flujo Acelerado de Reembolso"
+3
View File
@@ -0,0 +1,3 @@
from .sii import SIIService
__all__ = ['SIIService']
+193
View File
@@ -0,0 +1,193 @@
"""
SII and Chilean Tax / Document Validation Service for EFINTEL ERP.
Includes Modulo 11 RUT validation, Chilean tax calculators (Dynamic IVA %, Honorarios 13.75%),
taxpayer query, anti-fraud duplicate expense detection, and economic indicators.
"""
from decimal import Decimal, ROUND_HALF_UP
import re
from typing import Tuple, List, Optional, Dict, Any
from django.db.models import Q
class SIIService:
DEFAULT_IVA_RATE = Decimal('0.19')
HONORARIOS_RETENTION_RATE = Decimal('0.1375') # Current Chilean 13.75% retention
@classmethod
def get_current_iva_rate(cls) -> Decimal:
"""Fetch dynamic IVA rate from SystemSetting or fallback to default 19%."""
try:
from expenses.models import SystemSetting
setting = SystemSetting.objects.first()
if setting and setting.iva_rate:
return setting.iva_rate / Decimal('100.0')
except Exception:
pass
return cls.DEFAULT_IVA_RATE
@staticmethod
def clean_rut(rut: str) -> str:
"""Removes dots, hyphens, and whitespace, converting verification digit to uppercase."""
if not rut:
return ""
return re.sub(r'[^0-9kK]', '', str(rut)).upper()
@classmethod
def validate_rut(cls, rut: str) -> Tuple[bool, str]:
"""
Validates Chilean RUT using strict Modulo 11 algorithm.
Returns (is_valid: bool, formatted_rut_or_error: str).
"""
cleaned = cls.clean_rut(rut)
if len(cleaned) < 2:
return False, "RUT demasiado corto."
body = cleaned[:-1]
dv = cleaned[-1]
if not body.isdigit():
return False, "El cuerpo del RUT debe contener sólo dígitos."
# Modulo 11 calculation
reversed_digits = [int(d) for d in reversed(body)]
factors = [2, 3, 4, 5, 6, 7]
total = 0
for i, digit in enumerate(reversed_digits):
factor = factors[i % len(factors)]
total += digit * factor
remainder = total % 11
expected_dv_num = 11 - remainder
if expected_dv_num == 11:
expected_dv = '0'
elif expected_dv_num == 10:
expected_dv = 'K'
else:
expected_dv = str(expected_dv_num)
if dv == expected_dv:
formatted_body = f"{int(body):,}".replace(',', '.')
formatted_rut = f"{formatted_body}-{dv}"
return True, formatted_rut
else:
return False, f"Dígito verificador inválido (se esperaba {expected_dv})."
@classmethod
def get_taxpayer_info(cls, rut: str) -> Dict[str, Any]:
"""
Queries taxpayer information (Razón Social, Giro, Status) from database or SII lookup service.
"""
is_valid, formatted_rut = cls.validate_rut(rut)
if not is_valid:
return {'valid': False, 'error': formatted_rut}
from expenses.models import Vendor
vendor = Vendor.objects.filter(Q(rut=formatted_rut) | Q(rut=cls.clean_rut(rut))).first()
if vendor:
return {
'valid': True,
'rut': vendor.rut,
'name': vendor.name,
'business_line': vendor.business_line or 'Giro No Especificado',
'bank': vendor.bank or '',
'account_type': vendor.account_type or '',
'account_number': vendor.account_number or '',
'source': 'ERP_DB'
}
# Simulated SII API Response for validated RUTs
return {
'valid': True,
'rut': formatted_rut,
'name': f"CONTRIBUYENTE SII {formatted_rut}",
'business_line': "Servicios de Ingeniería, Tecnología y Consultoría I+D+i",
'bank': "",
'account_type': "",
'account_number': "",
'source': 'SII_API'
}
@classmethod
def calculate_taxes(cls, net_amount_clp: Decimal, tax_type: str, custom_iva_percent: Optional[Decimal] = None) -> dict:
"""
Calculates tax amounts and gross/net totals based on Chilean tax type and dynamic IVA %:
- IVA_19 / AFECTO: Total = Neto + (Neto * IVA%)
- HONORARIOS_13_75: Retention 13.75%
- EXENTO: Total = Neto, Tax = 0
"""
if not net_amount_clp or net_amount_clp < 0:
net_amount_clp = Decimal('0')
if custom_iva_percent is not None:
iva_rate = custom_iva_percent / Decimal('100.0')
iva_percent_val = custom_iva_percent
else:
iva_rate = cls.get_current_iva_rate()
iva_percent_val = (iva_rate * Decimal('100.0')).quantize(Decimal('0.1'))
if tax_type in ('IVA_19', 'IVA', 'AFECTO'):
tax_amount = (net_amount_clp * iva_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
total_amount = net_amount_clp + tax_amount
return {
'tax_type': 'IVA_19',
'net_amount': net_amount_clp,
'tax_rate_percent': iva_percent_val,
'tax_amount': tax_amount,
'total_amount': total_amount,
'description': f'Afecto a IVA ({iva_percent_val:g}%)'
}
elif tax_type in ('HONORARIOS_13_75', 'HONORARIOS'):
tax_amount = (net_amount_clp * cls.HONORARIOS_RETENTION_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
total_amount = net_amount_clp + tax_amount
return {
'tax_type': 'HONORARIOS_13_75',
'net_amount': net_amount_clp,
'tax_rate_percent': Decimal('13.75'),
'tax_amount': tax_amount,
'total_amount': total_amount,
'description': 'Retención Boleta de Honorarios 13.75%'
}
else:
return {
'tax_type': 'EXENTO',
'net_amount': net_amount_clp,
'tax_rate_percent': Decimal('0.0'),
'tax_amount': Decimal('0'),
'total_amount': net_amount_clp,
'description': 'Exento / No Afecto a Impuestos'
}
@staticmethod
def check_duplicate_expense(
expense_model,
vendor_id: Optional[int],
amount_clp: Decimal,
date_val,
document_number: Optional[str] = None,
exclude_id: Optional[int] = None
) -> List:
"""
Detects potential duplicate expenses to prevent fraud or double-reimbursement.
"""
if not amount_clp:
return []
query = Q()
if document_number and str(document_number).strip():
doc_clean = str(document_number).strip()
if vendor_id:
query |= Q(vendor_id=vendor_id, document_number__iexact=doc_clean)
else:
query |= Q(document_number__iexact=doc_clean)
if vendor_id and date_val:
query |= Q(vendor_id=vendor_id, amount_clp=amount_clp, date=date_val)
if not query:
return []
qs = expense_model.objects.filter(query)
if exclude_id:
qs = qs.exclude(id=exclude_id)
return list(qs.select_related('vendor', 'project', 'user')[:5])
+175 -2
View File
@@ -1,3 +1,176 @@
from django.test import TestCase
from django.test import TestCase, Client
from django.contrib.auth.models import User, Group
from decimal import Decimal
from datetime import date
from expenses.models import (
Expense, Project, Category, SystemSetting, Vendor, ProjectPhase,
ProjectStatusEnum, TaxTypeEnum, ExpenseTypeEnum, VehicleLog
)
from expenses.services.sii import SIIService
from talent.models import SpecialistCategoryEnum, SpecialistCandidate
from procurement.models import ItemCatalog, Category as ProcurementCategory
# Create your tests here.
class ERPRequirementsTestCase(TestCase):
def setUp(self):
self.client = Client()
self.admin = User.objects.create_superuser('admin_user', 'admin@efintel.cl', 'password123')
self.pm_group, _ = Group.objects.get_or_create(name='Director de Proyecto')
self.pm_user = User.objects.create_user('pm_user', 'pm@efintel.cl', 'password123')
self.pm_user.groups.add(self.pm_group)
self.setting = SystemSetting.objects.create(id=1, usd_exchange_rate=950.0, iva_rate=19.00)
self.category_combustible = Category.objects.create(code='3101', name='Combustibles / Bencina')
self.category_office = Category.objects.create(code='2301', name='Arriendo de Oficinas y Espacios de Trabajo')
self.category_courses = Category.objects.create(code='4201', name='Cursos, Capacitación y Certificaciones Técnicas para Especialistas')
self.category_ai = Category.objects.create(code='7000', name='Software, Datos, Inteligencia Artificial y Cloud')
self.project_idi = Project.objects.create(
code='CC-IDI-01',
name='Centro I+D+i Central',
assigned_budget_clp=10000000,
payment_method='Mensual',
total_income_clp=15000000,
type=ExpenseTypeEnum.COSTO_DIRECTO,
status=ProjectStatusEnum.ACTIVO,
manager=self.pm_user
)
def test_req_1_and_5_dynamic_iva_and_settings(self):
"""Test Requerimiento 1 (No global_budget in dashboard) & Requerimiento 5 (Dynamic IVA)."""
self.client.login(username='admin_user', password='password123')
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertNotIn('PRESUPUESTO GENERAL I+D+i', response.content.decode())
# Test Dynamic IVA (Change to 21%)
self.setting.iva_rate = Decimal('21.00')
self.setting.save()
calc = SIIService.calculate_taxes(Decimal('100000'), 'IVA_19')
self.assertEqual(calc['tax_amount'], Decimal('21000'))
self.assertEqual(calc['total_amount'], Decimal('121000'))
def test_req_2_exact_project_count(self):
"""Test Requerimiento 2: Exact project count by status."""
Project.objects.create(code='PROY-P1', name='Pausado', status=ProjectStatusEnum.PAUSADA)
Project.objects.create(code='PROY-T1', name='Terminado', status=ProjectStatusEnum.TERMINADO)
Project.objects.create(code='PROY-D1', name='Borrador', status=ProjectStatusEnum.BORRADOR)
self.client.login(username='admin_user', password='password123')
res = self.client.get('/proyectos/')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.context['project_count_active'], 1)
self.assertEqual(res.context['project_count_paused'], 1)
self.assertEqual(res.context['project_count_completed'], 1)
self.assertEqual(res.context['project_count_draft'], 1)
self.assertEqual(res.context['project_count_total'], 4)
def test_req_4_sii_integration(self):
"""Test Requerimiento 4: SII API service and taxpayer info query."""
is_valid, formatted = SIIService.validate_rut('76543210-3')
self.assertTrue(is_valid)
self.assertEqual(formatted, '76.543.210-3')
info = SIIService.get_taxpayer_info('76.543.210-3')
self.assertTrue(info['valid'])
self.assertEqual(info['rut'], '76.543.210-3')
def test_req_6_vendor_draft_mitigation(self):
"""Test Requerimiento 6: Vendor registration error mitigation and draft in session."""
self.client.login(username='admin_user', password='password123')
# Submit invalid vendor form (RUT invalid - fails Modulo 11 check)
response = self.client.post('/gestion/proveedores/nuevo/', {
'rut': '12345678-0',
'name': 'Vendor Test Draft',
'payment_terms': 30
})
self.assertEqual(response.status_code, 200)
self.assertIn('vendor_draft', self.client.session)
def test_req_7_is_reimbursement_logic(self):
"""Test Requerimiento 7: Erogación rendición vs centro I+D+i."""
expense = Expense.objects.create(
project=self.project_idi,
category=self.category_office,
is_reimbursement=False,
amount_clp=50000,
date=date.today(),
description='Gasto I+D+i directo',
user=self.admin
)
self.assertFalse(expense.is_reimbursement)
# Reimbursement expense
expense_reimb = Expense.objects.create(
project=self.project_idi,
category=self.category_office,
is_reimbursement=True,
responsible_person=self.pm_user,
amount_clp=30000,
date=date.today(),
description='Rendición de pasajes',
user=self.admin
)
self.assertTrue(expense_reimb.is_reimbursement)
self.assertEqual(expense_reimb.responsible_person, self.pm_user)
def test_req_8_9_10_project_detail_and_phase_balances(self):
"""Test Requerimiento 8, 9, 10: Project detail, progressive creation, and phase balances (+ / -)."""
phase1 = ProjectPhase.objects.create(
project=self.project_idi,
phase_number=1,
name='Fase 1: Prototipado',
planned_amount_clp=5000000,
income_amount_clp=6000000,
expense_amount_clp=2000000
)
self.assertEqual(phase1.balance_clp, Decimal('4000000')) # Positive balance +$4.000.000
phase2 = ProjectPhase.objects.create(
project=self.project_idi,
phase_number=2,
name='Fase 2: Pruebas Terreno',
planned_amount_clp=3000000,
income_amount_clp=1000000,
expense_amount_clp=2500000
)
self.assertEqual(phase2.balance_clp, Decimal('-1500000')) # Negative balance -$1.500.000
self.client.login(username='admin_user', password='password123')
res = self.client.get(f'/proyectos/{self.project_idi.id}/')
self.assertEqual(res.status_code, 200)
self.assertIn('Fases de Pago e Hitos del Proyecto', res.content.decode())
def test_req_11_12_13_categories_taxonomy(self):
"""Test Requerimiento 11, 12, 13: Talent category 7, Office Rental, and Specialist Courses."""
# Req 11
self.assertEqual(SpecialistCategoryEnum.CAT_7_SOFTWARE_DATOS_IA_CLOUD, '7')
self.assertEqual(SpecialistCategoryEnum.CAT_7_SOFTWARE_DATOS_IA_CLOUD.label, '7. Software, Datos, IA y Cloud')
# Req 12
self.assertEqual(self.category_office.code, '2301')
# Req 13
self.assertEqual(self.category_courses.code, '4201')
def test_req_14_vehicle_category_strict_combustible(self):
"""Test Requerimiento 14: Vehicle log applies ONLY to combustible category."""
exp_combustible = Expense.objects.create(
project=self.project_idi,
category=self.category_combustible,
amount_clp=40000,
date=date.today(),
description='Carga Combustible Copec',
user=self.admin
)
self.assertTrue(exp_combustible.is_vehicle_category())
exp_office = Expense.objects.create(
project=self.project_idi,
category=self.category_office,
amount_clp=150000,
date=date.today(),
description='Pago Arriendo Oficina',
user=self.admin
)
self.assertFalse(exp_office.is_vehicle_category())
+13
View File
@@ -12,6 +12,12 @@ urlpatterns = [
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'),
# Rendiciones Agrupadas (Informes de Viaje / Faena)
path('rendiciones/', views.expense_report_list, name='expense_report_list'),
path('rendiciones/nueva/', views.expense_report_create, name='expense_report_create'),
path('rendiciones/<int:pk>/', views.expense_report_detail, name='expense_report_detail'),
path('rendiciones/<int:pk>/accion/<str:action>/', views.expense_report_action, name='expense_report_action'),
# Flujo de aprobación y Cuentas por Pagar
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
path('gastos/<int:pk>/aprobar/<str:action>/', views.approve_expense, name='approve_expense'),
@@ -21,6 +27,8 @@ urlpatterns = [
path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'),
path('proyectos/', views.project_list, name='project_list'),
path('proyectos/<int:pk>/', views.project_detail, name='project_detail'),
path('proyectos/<int:pk>/fase/nueva/', views.project_add_phase, name='project_add_phase'),
path('gestion/', views.gestion_panel, name='gestion_panel'),
path('gestion/configuracion/', views.settings_edit, name='settings_edit'),
@@ -40,4 +48,9 @@ urlpatterns = [
path('gestion/responsables/nuevo/', views.responsable_create, name='responsable_create'),
path('gestion/responsables/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
path('gestion/responsables/<int:pk>/eliminar/', views.responsable_delete, name='responsable_delete'),
# SII AJAX Endpoints
path('api/sii/validate-rut/', views.api_sii_validate_rut, name='api_sii_validate_rut'),
path('api/sii/taxpayer-info/', views.api_sii_taxpayer_info, name='api_sii_taxpayer_info'),
path('api/sii/calculate-taxes/', views.api_sii_calculate_taxes, name='api_sii_calculate_taxes'),
]
+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']
})