iteracion 2
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .sii import SIIService
|
||||
|
||||
__all__ = ['SIIService']
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -1,10 +1,13 @@
|
||||
import csv
|
||||
import io
|
||||
from decimal import Decimal
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.contrib.auth import login, authenticate, logout
|
||||
from django.contrib import messages
|
||||
from django.db.models import Sum, Count, F
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from datetime import date
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
@@ -37,6 +40,7 @@ def logout_view(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad)
|
||||
def dashboard(request):
|
||||
today = date.today()
|
||||
first_day_of_month = today.replace(day=1)
|
||||
@@ -49,18 +53,21 @@ def dashboard(request):
|
||||
total=Sum('amount_clp')
|
||||
)['total'] or 0
|
||||
|
||||
project_count = Project.objects.count()
|
||||
# Requerimiento 2: Conteo exacto de proyectos reales (excluyendo portafolios)
|
||||
from .models import ProjectStatusEnum
|
||||
real_projects = Project.objects.filter(parent__parent__isnull=False)
|
||||
project_count_active = real_projects.filter(status=ProjectStatusEnum.ACTIVO).count()
|
||||
project_count_paused = real_projects.filter(status=ProjectStatusEnum.PAUSADA).count()
|
||||
project_count_completed = real_projects.filter(status=ProjectStatusEnum.TERMINADO).count()
|
||||
project_count_draft = real_projects.filter(status=ProjectStatusEnum.BORRADOR).count()
|
||||
project_count_total = real_projects.count()
|
||||
|
||||
today_count = Expense.objects.filter(date=today).count()
|
||||
|
||||
recent_expenses = Expense.objects.select_related(
|
||||
'project', 'category', 'user', 'responsible_person'
|
||||
).order_by('-date')[:10]
|
||||
|
||||
# Presupuesto Global
|
||||
setting, _ = SystemSetting.objects.get_or_create(id=1)
|
||||
global_budget = setting.global_budget
|
||||
remaining_budget = global_budget - total_all_clp
|
||||
|
||||
# Chart Data: Gastos por Categoría
|
||||
cat_expenses = Expense.objects.values('category__name').annotate(total=Sum('amount_clp')).order_by('-total')
|
||||
category_labels = [item['category__name'] or 'Sin Categoría' for item in cat_expenses]
|
||||
@@ -74,11 +81,13 @@ def dashboard(request):
|
||||
context = {
|
||||
'total_month': total_month_clp,
|
||||
'total_all': total_all_clp,
|
||||
'project_count': project_count,
|
||||
'project_count_active': project_count_active,
|
||||
'project_count_paused': project_count_paused,
|
||||
'project_count_completed': project_count_completed,
|
||||
'project_count_draft': project_count_draft,
|
||||
'project_count_total': project_count_total,
|
||||
'today_count': today_count,
|
||||
'recent_expenses': recent_expenses,
|
||||
'global_budget': global_budget,
|
||||
'remaining_budget': remaining_budget,
|
||||
'category_labels_json': json.dumps(category_labels),
|
||||
'category_data_json': json.dumps(category_data),
|
||||
'project_labels_json': json.dumps(project_labels),
|
||||
@@ -111,6 +120,7 @@ def expense_list(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@transaction.atomic # Integridad de datos
|
||||
def expense_create(request):
|
||||
# Only allow creation if user is admin or allowed to
|
||||
if request.method == 'POST':
|
||||
@@ -419,13 +429,49 @@ def export_bank_transfer_csv(request):
|
||||
|
||||
@login_required
|
||||
def project_list(request):
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
|
||||
is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups
|
||||
|
||||
projects = Project.objects.annotate(
|
||||
expense_count=Count('expenses'),
|
||||
total_expenses=Sum('expenses__amount_clp')
|
||||
).select_related('manager')
|
||||
).select_related('manager', 'category', 'parent').prefetch_related('phases', 'expenses').filter(
|
||||
parent__parent__isnull=False
|
||||
).order_by('-created_at')
|
||||
|
||||
# If PM and not admin, show managed or assigned projects
|
||||
from django.db.models import Q
|
||||
if is_pm and not is_admin:
|
||||
projects = projects.filter(Q(manager=request.user) | Q(created_by=request.user))
|
||||
|
||||
# Filters
|
||||
status_filter = request.GET.get('status')
|
||||
search_query = request.GET.get('search')
|
||||
|
||||
if status_filter:
|
||||
projects = projects.filter(status=status_filter)
|
||||
if search_query:
|
||||
projects = projects.filter(Q(name__icontains=search_query) | Q(code__icontains=search_query))
|
||||
|
||||
|
||||
# Requerimiento 2: Conteos exactos por estado
|
||||
from .models import ProjectStatusEnum
|
||||
project_count_active = projects.filter(status=ProjectStatusEnum.ACTIVO).count()
|
||||
project_count_paused = projects.filter(status=ProjectStatusEnum.PAUSADA).count()
|
||||
project_count_completed = projects.filter(status=ProjectStatusEnum.TERMINADO).count()
|
||||
project_count_draft = projects.filter(status=ProjectStatusEnum.BORRADOR).count()
|
||||
project_count_total = projects.count()
|
||||
|
||||
context = {
|
||||
'projects': projects,
|
||||
'is_admin': is_admin,
|
||||
'is_pm': is_pm,
|
||||
'project_count_active': project_count_active,
|
||||
'project_count_paused': project_count_paused,
|
||||
'project_count_completed': project_count_completed,
|
||||
'project_count_draft': project_count_draft,
|
||||
'project_count_total': project_count_total,
|
||||
}
|
||||
return render(request, 'expenses/project_list.html', context)
|
||||
|
||||
@@ -467,34 +513,125 @@ def settings_edit(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@role_required('Administrador')
|
||||
def project_detail(request, pk):
|
||||
"""
|
||||
Requerimiento 9 & 10: Detalle del Proyecto con Presupuesto asignado, Forma de pago,
|
||||
Ingresos, Egresos, Saldo neto y Fases de pago con saldo a favor (+ / -).
|
||||
"""
|
||||
project = get_object_or_404(
|
||||
Project.objects.select_related('manager', 'category', 'parent', 'created_by')
|
||||
.prefetch_related('phases', 'expenses__vendor', 'expenses__documents', 'purchase_orders'),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
|
||||
is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups
|
||||
|
||||
phases = project.phases.all()
|
||||
expenses = project.expenses.select_related('vendor', 'category', 'user').order_by('-date')
|
||||
|
||||
# Calculations
|
||||
total_income = project.total_income_clp or Decimal('0.00')
|
||||
total_expenses = project.total_expenses_clp or Decimal('0.00')
|
||||
net_balance = project.balance_clp
|
||||
budget_balance = project.budget_balance_clp
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'phases': phases,
|
||||
'expenses': expenses,
|
||||
'total_income': total_income,
|
||||
'total_expenses': total_expenses,
|
||||
'net_balance': net_balance,
|
||||
'budget_balance': budget_balance,
|
||||
'is_admin': is_admin,
|
||||
'is_pm': is_pm,
|
||||
}
|
||||
return render(request, 'expenses/project_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def project_add_phase(request, pk):
|
||||
"""
|
||||
Requerimiento 10: Agregar / actualizar fases de pago para un proyecto.
|
||||
"""
|
||||
project = get_object_or_404(Project, pk=pk)
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups
|
||||
if not (is_admin or project.manager == request.user or 'Director de Proyecto' in user_groups):
|
||||
messages.error(request, 'No tienes permisos para modificar las fases de este proyecto.')
|
||||
return redirect('project_detail', pk=pk)
|
||||
|
||||
from .forms import ProjectPhaseForm
|
||||
if request.method == 'POST':
|
||||
form = ProjectPhaseForm(request.POST)
|
||||
if form.is_valid():
|
||||
phase = form.save(commit=False)
|
||||
phase.project = project
|
||||
phase.save()
|
||||
messages.success(request, f"Fase {phase.phase_number} ({phase.name}) agregada al proyecto.")
|
||||
return redirect('project_detail', pk=pk)
|
||||
else:
|
||||
next_num = project.phases.count() + 1
|
||||
form = ProjectPhaseForm(initial={'phase_number': next_num})
|
||||
|
||||
return render(request, 'expenses/project_phase_form.html', {'form': form, 'project': project})
|
||||
|
||||
|
||||
@login_required
|
||||
def project_create(request):
|
||||
"""
|
||||
Requerimiento 8: Creación progresiva para director de proyectos y RBAC granular.
|
||||
"""
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups
|
||||
is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups or is_admin
|
||||
|
||||
if not is_pm:
|
||||
messages.error(request, 'No tienes permisos para crear proyectos.')
|
||||
return redirect('project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ProjectForm(request.POST)
|
||||
if form.is_valid():
|
||||
project = form.save(commit=False)
|
||||
project.created_by = request.user
|
||||
if not project.manager and is_pm and not is_admin:
|
||||
project.manager = request.user
|
||||
project.save()
|
||||
messages.success(request, 'Proyecto creado exitosamente.')
|
||||
return redirect('gestion_panel')
|
||||
messages.success(request, f"Proyecto {project.code} - {project.name} creado exitosamente.")
|
||||
return redirect('project_detail', pk=project.pk)
|
||||
else:
|
||||
form = ProjectForm()
|
||||
return render(request, 'expenses/project_form.html', {'form': form})
|
||||
# Default code generation
|
||||
count = Project.objects.count() + 1
|
||||
default_code = f"PROY-2026-{count:03d}"
|
||||
form = ProjectForm(initial={'code': default_code, 'manager': request.user})
|
||||
|
||||
return render(request, 'expenses/project_form.html', {'form': form, 'title': 'Nuevo Proyecto / Centro de Costo'})
|
||||
|
||||
|
||||
@login_required
|
||||
@role_required('Administrador')
|
||||
def project_edit(request, pk):
|
||||
project = get_object_or_404(Project, pk=pk)
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups
|
||||
is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups or is_admin
|
||||
|
||||
if not is_pm:
|
||||
messages.error(request, 'No tienes permisos para editar este proyecto.')
|
||||
return redirect('project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ProjectForm(request.POST, instance=project)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Proyecto actualizado exitosamente.')
|
||||
return redirect('gestion_panel')
|
||||
messages.success(request, f"Proyecto {project.code} actualizado exitosamente.")
|
||||
return redirect('project_detail', pk=project.pk)
|
||||
else:
|
||||
form = ProjectForm(instance=project)
|
||||
return render(request, 'expenses/project_form.html', {'form': form})
|
||||
|
||||
return render(request, 'expenses/project_form.html', {'form': form, 'project': project, 'title': f'Editar Proyecto: {project.code}'})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -504,7 +641,7 @@ def project_delete(request, pk):
|
||||
if request.method == 'POST':
|
||||
project.delete()
|
||||
messages.success(request, 'Proyecto eliminado.')
|
||||
return redirect('gestion_panel')
|
||||
return redirect('project_list')
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -547,32 +684,59 @@ def category_delete(request, pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@role_required('Administrador')
|
||||
def vendor_create(request):
|
||||
"""
|
||||
Requerimiento 6: Plan de mitigación de errores para registro de proveedores.
|
||||
Preserva datos en borrador (session/context) si ocurre un fallo o error de validación.
|
||||
"""
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
|
||||
if not is_admin:
|
||||
messages.error(request, 'No tienes permisos para registrar proveedores.')
|
||||
return redirect('dashboard')
|
||||
|
||||
draft_data = request.session.get('vendor_draft', {})
|
||||
|
||||
if request.method == 'POST':
|
||||
form = VendorForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Proveedor creado exitosamente.')
|
||||
vendor = form.save()
|
||||
if 'vendor_draft' in request.session:
|
||||
del request.session['vendor_draft']
|
||||
messages.success(request, f"Proveedor {vendor.rut} - {vendor.name} registrado exitosamente.")
|
||||
return redirect('gestion_panel')
|
||||
else:
|
||||
# Save POST data into session as draft mitigation
|
||||
request.session['vendor_draft'] = request.POST.dict()
|
||||
messages.warning(request, "Error en el formulario. Los datos ingresados han sido preservados para prevenir pérdida de información.")
|
||||
else:
|
||||
form = VendorForm()
|
||||
return render(request, 'expenses/vendor_form.html', {'form': form})
|
||||
initial = draft_data if draft_data else None
|
||||
form = VendorForm(initial=initial)
|
||||
|
||||
return render(request, 'expenses/vendor_form.html', {'form': form, 'title': 'Nuevo Proveedor', 'has_draft': bool(draft_data)})
|
||||
|
||||
|
||||
@login_required
|
||||
@role_required('Administrador')
|
||||
def vendor_edit(request, pk):
|
||||
vendor = get_object_or_404(Vendor, pk=pk)
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
|
||||
if not is_admin:
|
||||
messages.error(request, 'No tienes permisos para editar proveedores.')
|
||||
return redirect('dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = VendorForm(request.POST, instance=vendor)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Proveedor actualizado exitosamente.')
|
||||
messages.success(request, f"Proveedor {vendor.rut} actualizado exitosamente.")
|
||||
return redirect('gestion_panel')
|
||||
else:
|
||||
messages.warning(request, "Error de validación al actualizar el proveedor. Se conservan los cambios parciales.")
|
||||
else:
|
||||
form = VendorForm(instance=vendor)
|
||||
return render(request, 'expenses/vendor_form.html', {'form': form})
|
||||
|
||||
return render(request, 'expenses/vendor_form.html', {'form': form, 'title': f'Editar Proveedor: {vendor.rut}'})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -706,3 +870,137 @@ def delete_document(request, pk):
|
||||
doc.delete()
|
||||
return JsonResponse({'success': True})
|
||||
return JsonResponse({'success': False, 'error': 'Método no permitido.'}, status=405)
|
||||
|
||||
|
||||
# ----------------- EXPENSE REPORTS (RENDICIONES AGRUPADAS) -----------------
|
||||
from .models import ExpenseReport, VehicleLog, ExpenseReportStatusEnum
|
||||
from .forms import ExpenseReportForm, VehicleLogForm
|
||||
from .services.sii import SIIService
|
||||
|
||||
@login_required
|
||||
def expense_report_list(request):
|
||||
reports = ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses').all()
|
||||
|
||||
# Filter by user if not admin
|
||||
user_groups = request.user.groups.values_list('name', flat=True)
|
||||
is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
reports = reports.filter(status=status_filter)
|
||||
|
||||
return render(request, 'expenses/expense_report_list.html', {
|
||||
'reports': reports,
|
||||
'selected_status': status_filter,
|
||||
'is_admin': is_admin,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def expense_report_create(request):
|
||||
if request.method == 'POST':
|
||||
form = ExpenseReportForm(request.POST)
|
||||
if form.is_valid():
|
||||
rep = form.save(commit=False)
|
||||
rep.user = request.user
|
||||
count = ExpenseReport.objects.count() + 1
|
||||
rep.report_code = f"EXP-REP-2026-{count:04d}"
|
||||
rep.status = ExpenseReportStatusEnum.BORRADOR
|
||||
rep.save()
|
||||
messages.success(request, f"Informe de Rendición {rep.report_code} creado exitosamente. Ahora puede adjuntar gastos.")
|
||||
return redirect('expense_report_detail', pk=rep.pk)
|
||||
else:
|
||||
form = ExpenseReportForm()
|
||||
return render(request, 'expenses/expense_report_form.html', {'form': form, 'title': 'Nuevo Informe de Rendición de Gastos'})
|
||||
|
||||
@login_required
|
||||
def expense_report_detail(request, pk):
|
||||
report = get_object_or_404(ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses__vendor', 'expenses__category', 'expenses__documents', 'expenses__vehicle_log'), pk=pk)
|
||||
|
||||
# Available standalone expenses for this project and user
|
||||
available_expenses = Expense.objects.filter(
|
||||
report__isnull=True,
|
||||
user=report.user,
|
||||
project=report.project
|
||||
).order_by('-date')
|
||||
|
||||
return render(request, 'expenses/expense_report_detail.html', {
|
||||
'report': report,
|
||||
'available_expenses': available_expenses,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def expense_report_action(request, pk, action):
|
||||
report = get_object_or_404(ExpenseReport, pk=pk)
|
||||
if request.method == 'POST':
|
||||
if action == 'submit':
|
||||
report.status = ExpenseReportStatusEnum.ENVIADO
|
||||
messages.success(request, f"Rendición {report.report_code} enviada a revisión y aprobación de Finanzas.")
|
||||
elif action == 'approve':
|
||||
report.status = ExpenseReportStatusEnum.APROBADO
|
||||
report.approved_by = request.user
|
||||
report.approved_at = timezone.now()
|
||||
# Also elevate all child expenses to SOLICITADO_FINANZAS
|
||||
report.expenses.update(approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS, elevated_by=request.user)
|
||||
messages.success(request, f"Rendición {report.report_code} aprobada por Finanzas.")
|
||||
elif action == 'reimburse':
|
||||
report.status = ExpenseReportStatusEnum.REEMBOLSADO
|
||||
report.expenses.update(approval_status=ApprovalStatusEnum.LIBERADO_PAGADO, paid_by=request.user, paid_at=timezone.now())
|
||||
messages.success(request, f"Rendición {report.report_code} marcada como Reembolsada / Pagada.")
|
||||
elif action == 'observe':
|
||||
report.status = ExpenseReportStatusEnum.OBSERVADO
|
||||
report.notes += f"\nObservación ({timezone.now().strftime('%d/%m/%Y')}) por {request.user.username}: " + request.POST.get('reason', '')
|
||||
messages.warning(request, f"Rendición {report.report_code} observada.")
|
||||
elif action == 'add_expense':
|
||||
expense_id = request.POST.get('expense_id')
|
||||
if expense_id:
|
||||
exp = Expense.objects.filter(id=expense_id, user=report.user).first()
|
||||
if exp:
|
||||
exp.report = report
|
||||
exp.save(update_fields=['report'])
|
||||
messages.success(request, f"Gasto {exp.expense_code} añadido a la rendición.")
|
||||
elif action == 'remove_expense':
|
||||
expense_id = request.POST.get('expense_id')
|
||||
if expense_id:
|
||||
exp = Expense.objects.filter(id=expense_id, report=report).first()
|
||||
if exp:
|
||||
exp.report = None
|
||||
exp.save(update_fields=['report'])
|
||||
messages.success(request, f"Gasto {exp.expense_code} desvinculado de la rendición.")
|
||||
|
||||
report.save()
|
||||
return redirect('expense_report_detail', pk=pk)
|
||||
|
||||
# API / AJAX helper for SII RUT check, taxpayer info & tax calculation
|
||||
@login_required
|
||||
def api_sii_validate_rut(request):
|
||||
rut = request.GET.get('rut', '')
|
||||
is_valid, formatted = SIIService.validate_rut(rut)
|
||||
return JsonResponse({'valid': is_valid, 'formatted': formatted})
|
||||
|
||||
@login_required
|
||||
def api_sii_taxpayer_info(request):
|
||||
"""
|
||||
Requerimiento 4: Información e integración con la API del SII.
|
||||
"""
|
||||
rut = request.GET.get('rut', '')
|
||||
info = SIIService.get_taxpayer_info(rut)
|
||||
return JsonResponse(info)
|
||||
|
||||
@login_required
|
||||
def api_sii_calculate_taxes(request):
|
||||
net_str = request.GET.get('net_amount', '0').replace('.', '').replace(',', '.')
|
||||
tax_type = request.GET.get('tax_type', 'IVA_19')
|
||||
try:
|
||||
net = Decimal(net_str)
|
||||
except Exception:
|
||||
net = Decimal('0')
|
||||
res = SIIService.calculate_taxes(net, tax_type)
|
||||
return JsonResponse({
|
||||
'tax_type': res['tax_type'],
|
||||
'net_amount': float(res['net_amount']),
|
||||
'tax_rate_percent': float(res.get('tax_rate_percent', 19.0)),
|
||||
'tax_amount': float(res['tax_amount']),
|
||||
'total_amount': float(res['total_amount']),
|
||||
'description': res['description']
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user