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