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, ExpenseReport, VehicleLog, ExpenseReportStatusEnum ) from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES from .services.sii import SIIService from decimal import Decimal import os class MultipleFileInput(forms.FileInput): allow_multiple_selected = True def value_from_datadict(self, data, files, name): if hasattr(files, 'getlist'): return files.getlist(name) else: return files.get(name) class MultipleFileField(forms.FileField): def __init__(self, *args, **kwargs): kwargs.setdefault("widget", MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'})) super().__init__(*args, **kwargs) def clean(self, data, initial=None): single_file_clean = super().clean if isinstance(data, (list, tuple)): result = [single_file_clean(d, initial) for d in data] else: result = single_file_clean(data, initial) return result class ExpenseForm(forms.ModelForm): date = forms.DateField( widget=forms.DateInput(attrs={'type': 'date'}), 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', '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( label='Adjuntar Evidencia(s) (Boleta / Factura)', required=False ) region = forms.ChoiceField( choices=[('', '--- Seleccione Región ---')] + REGION_CHOICES, label='Región Operativa', required=False ) comuna = forms.ChoiceField( choices=[('', '--- Seleccione Comuna ---')] + COMMUNE_CHOICES, label='Comuna', required=False ) vehicle_ownership = forms.ChoiceField( choices=[('', '--- Seleccione Modalidad ---')] + VehicleOwnershipEnum.choices, label='Modalidad / Vehículo', required=False ) tax_amount_clp = forms.CharField( label='Monto Impuesto (CLP)', required=False, 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', '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', 'x-model': 'newVendorRut', '@blur': 'validateRut()', ':class': "rutError ? 'border-red-500' : ''"}) ) new_vendor_name = forms.CharField( label="Nombre / Razón Social Nuevo Proveedor", required=False, 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', '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', '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: raise forms.ValidationError("Este campo es obligatorio.") data_clean = str(data).replace('.', '').replace(',', '.') try: return Decimal(data_clean) except Exception: raise forms.ValidationError("Ingrese un monto válido (ej: 1.450.000).") def clean_tax_amount_clp(self): data = self.cleaned_data.get('tax_amount_clp', '') if not data: return Decimal('0.00') data_clean = str(data).replace('.', '').replace(',', '.') try: return Decimal(data_clean) except Exception: return Decimal('0.00') def clean_total_amount_clp(self): data = self.cleaned_data.get('total_amount_clp', '') if not data: return Decimal('0.00') data_clean = str(data).replace('.', '').replace(',', '.') try: return Decimal(data_clean) except Exception: return Decimal('0.00') def clean_receipt(self): files = self.files.getlist('receipt') for f in files: ext = os.path.splitext(f.name)[1].lower() if ext not in ['.pdf', '.png', '.jpg', '.jpeg']: raise forms.ValidationError(f"El archivo '{f.name}' no tiene una extensión permitida (PDF, PNG, JPG, JPEG).") return self.cleaned_data.get('receipt') 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): instance = super().save(commit=False) new_name = self.cleaned_data.get('new_vendor_name') new_rut = self.cleaned_data.get('new_vendor_rut') if new_name and new_rut: vendor, _ = Vendor.objects.get_or_create( rut=new_rut.strip(), defaults={'name': new_name.strip()} ) 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', 'manager', 'assigned_budget_clp', 'assigned_budget_usd', 'payment_method', 'total_income_clp', 'description' ] widgets = { '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'}), } class CategoryForm(forms.ModelForm): class Meta: model = Category fields = ['code', 'name', 'parent', 'description'] widgets = { 'description': forms.Textarea(attrs={'rows': 3}), } 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 = ['iva_rate', 'usd_exchange_rate'] class ResponsableForm(forms.Form): username = forms.CharField(max_length=150, label="Nombre de Usuario") password = forms.CharField(widget=forms.PasswordInput(), label="Contraseña", required=False) email = forms.EmailField(label="Correo Electrónico", required=False) role = forms.ModelChoiceField(queryset=Group.objects.all(), label="Rol / Grupo") # Perfil Fields rut = forms.CharField(max_length=20, label="RUT", required=False) job_title = forms.CharField(max_length=150, label="Cargo", required=False) description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), label="Descripción / Bio", required=False) specialties = forms.ModelMultipleChoiceField( queryset=Category.objects.all(), required=False, widget=forms.CheckboxSelectMultiple(), label="Especialidades Técnicas (Categorías)" ) def __init__(self, *args, **kwargs): self.instance = kwargs.pop('instance', None) super().__init__(*args, **kwargs) if self.instance: self.fields['username'].initial = self.instance.username self.fields['email'].initial = self.instance.email groups = self.instance.groups.all() if groups.exists(): self.fields['role'].initial = groups.first() try: self.fields['description'].initial = self.instance.profile.description self.fields['rut'].initial = self.instance.profile.rut self.fields['job_title'].initial = self.instance.profile.job_title self.fields['specialties'].initial = self.instance.profile.specialties.all() except Exception: pass self.fields['password'].required = False self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla." def clean_username(self): username = self.cleaned_data.get('username') qs = User.objects.filter(username=username) if self.instance: qs = qs.exclude(pk=self.instance.pk) if qs.exists(): raise forms.ValidationError("Este nombre de usuario ya está registrado.") return username def save(self): username = self.cleaned_data.get('username') password = self.cleaned_data.get('password') email = self.cleaned_data.get('email') role = self.cleaned_data.get('role') description = self.cleaned_data.get('description') rut = self.cleaned_data.get('rut') job_title = self.cleaned_data.get('job_title') if self.instance: user = self.instance user.username = username user.email = email if password: user.set_password(password) else: user = User.objects.create_user(username=username, email=email, password=password) if role.name == 'Administrador' or role.name == 'Administrador Finanzas': user.is_staff = True user.is_superuser = True else: user.is_staff = False user.is_superuser = False user.save() user.groups.clear() user.groups.add(role) profile, _ = UserProfile.objects.get_or_create(user=user) profile.description = description profile.rut = rut profile.job_title = job_title profile.save() specialties = self.cleaned_data.get('specialties') if specialties is not None: profile.specialties.set(specialties) return user class DocumentForm(forms.ModelForm): class Meta: model = Document fields = ['file', 'title']