{{ expense.responsible_person.username|default:expense.user.username }} + {% if expense.documents.all %} +
Arrastre sus boletas/facturas aquí o haga clic para buscar
+ Formatos aceptados: PDF, PNG, JPG, JPEG (Múltiple) +diff --git a/config/settings.py b/config/settings.py index a2bdb8b..85c717d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -115,7 +115,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/6.1/topics/i18n/ -LANGUAGE_CODE = 'es-es' +LANGUAGE_CODE = 'es-cl' TIME_ZONE = 'UTC' diff --git a/expenses/forms.py b/expenses/forms.py index 58eb77d..9387443 100644 --- a/expenses/forms.py +++ b/expenses/forms.py @@ -4,25 +4,40 @@ from .models import Expense, Project, Category, SystemSetting, UserProfile, Docu from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES +from decimal import Decimal +import os + class MultipleFileInput(forms.FileInput): allow_multiple_selected = True +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' ) - amount_clp = forms.DecimalField( + amount_clp = forms.CharField( label='Monto Neto (CLP)', required=True, - widget=forms.NumberInput(attrs={'placeholder': 'Ej: 1450000'}) + widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000'}) ) - receipt = forms.FileField( + receipt = MultipleFileField( label='Adjuntar Evidencia(s) (Boleta / Factura)', - required=False, - widget=MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'}) + required=False ) region = forms.ChoiceField( @@ -43,16 +58,28 @@ class ExpenseForm(forms.ModelForm): required=False ) - tax_amount_clp = forms.DecimalField( + tax_amount_clp = forms.CharField( label='Monto Impuesto (CLP)', required=False, - widget=forms.NumberInput(attrs={'readonly': 'readonly'}) + widget=forms.TextInput(attrs={'readonly': 'readonly'}) ) - total_amount_clp = forms.DecimalField( + total_amount_clp = forms.CharField( label='Monto Total (CLP)', required=False, - widget=forms.NumberInput(attrs={'readonly': 'readonly'}) + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) + + 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'}) + ) + + 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'}) ) class Meta: @@ -65,6 +92,68 @@ class ExpenseForm(forms.ModelForm): 'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}), } + 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() + new_name = cleaned_data.get('new_vendor_name') + new_rut = cleaned_data.get('new_vendor_rut') + + 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.") + 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: + from .models import Vendor + vendor, _ = Vendor.objects.get_or_create( + rut=new_rut.strip(), + defaults={'name': new_name.strip()} + ) + instance.vendor = vendor + if commit: + instance.save() + return instance + class ProjectForm(forms.ModelForm): class Meta: diff --git a/expenses/signals.py b/expenses/signals.py index e8417ce..26913ae 100644 --- a/expenses/signals.py +++ b/expenses/signals.py @@ -11,7 +11,7 @@ def expense_saved(sender, instance, created, **kwargs): expense_data = { 'id': instance.id, - 'amount': str(instance.amount), + 'amount': str(instance.amount_clp), 'category': instance.category.name if instance.category else None, 'project': instance.project.name if instance.project else None, 'user': instance.user.username if instance.user else None, diff --git a/expenses/urls.py b/expenses/urls.py index 77de5c0..52a8988 100644 --- a/expenses/urls.py +++ b/expenses/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ # Export / Import path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'), + path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'), path('proyectos/', views.project_list, name='project_list'), diff --git a/expenses/views.py b/expenses/views.py index 29e7257..7d1bd00 100644 --- a/expenses/views.py +++ b/expenses/views.py @@ -343,6 +343,59 @@ def export_expenses_csv(request): return response +@login_required +@role_required('Administrador', 'Administrador Finanzas') +def export_bank_transfer_csv(request): + # Get all expenses requested for payment that are linked to a vendor + pending_expenses = Expense.objects.filter( + approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS, + vendor__isnull=False + ) + + # Group debts by vendor to perform single bank transfer per supplier + debts = pending_expenses.values( + 'vendor__id', 'vendor__rut', 'vendor__name', 'vendor__bank', 'vendor__account_type', 'vendor__account_number' + ).annotate( + total_clp=Sum('amount_clp'), + total_usd=Sum('amount_usd'), + expense_count=Count('id') + ).order_by('vendor__name') + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="Nomina_Transferencias_{date.today()}.csv"' + + # Write BOM for Excel Spanish compatibility + response.write('\ufeff'.encode('utf-8')) + + writer = csv.writer(response, delimiter=';') + writer.writerow([ + 'RUT Proveedor', 'Nombre / Razón Social', 'Banco', 'Tipo Cuenta', + 'Número Cuenta', 'Monto Transferir (CLP)', 'Monto Transferir (USD)', + 'Comprobantes Pendientes', 'Glosa / Comentario' + ]) + + for debt in debts: + writer.writerow([ + debt['vendor__rut'], + debt['vendor__name'], + debt['vendor__bank'] or 'No Registrado', + debt['vendor__account_type'] or 'No Registrado', + debt['vendor__account_number'] or 'No Registrado', + int(debt['total_clp']), + round(debt['total_usd'], 2), + debt['expense_count'], + 'Pago Facturas Pendientes EFINTEL ERP' + ]) + + AuditLog.objects.create( + user=request.user, + action="EXPORT_BANK_TRANSFER", + description="Se exportó la nómina bancaria de transferencias a proveedores." + ) + + return response + + @login_required def project_list(request): projects = Project.objects.annotate( diff --git a/scratch/test_expense_integration.py b/scratch/test_expense_integration.py new file mode 100644 index 0000000..f0bd418 --- /dev/null +++ b/scratch/test_expense_integration.py @@ -0,0 +1,158 @@ +import os +import sys +import django +from django.test import Client +from django.core.files.uploadedfile import SimpleUploadedFile +from decimal import Decimal + +# Set up Django environment +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from django.conf import settings +if 'testserver' not in settings.ALLOWED_HOSTS: + settings.ALLOWED_HOSTS.append('testserver') + +from django.contrib.auth.models import User +from expenses.models import Project, Category, Vendor, Expense, Document, SystemSetting, CurrencyEnum, TaxTypeEnum, ApprovalStatusEnum + +def run_test(): + print("=" * 60) + print("EFINTEL ERP — Expense Creation Flow Integration Test") + print("=" * 60) + + # 1. Setup client and login + print("Step 1: Setting up Django test Client & Authenticating...") + client = Client() + admin_user = User.objects.filter(username='admin').first() + if not admin_user: + print("Admin user 'admin' not found in database. Creating user...") + admin_user = User.objects.create_superuser('admin', 'admin@example.com', 'admin') + print("Admin user created successfully.") + + client.force_login(admin_user) + print(f"Authenticated successfully as: {admin_user.username} (ID: {admin_user.id})") + + # Fetch required objects + print("\nFetching Project (PRY-101) and Category (1201)...") + project = Project.objects.filter(code='PRY-101').first() + if not project: + raise ValueError("Project PRY-101 not found! Please run project seed scripts first.") + print(f"Found Project: {project.code} - '{project.name}' (ID: {project.id})") + + category = Category.objects.filter(code='1201').first() + if not category: + raise ValueError("Category 1201 not found! Please run category seed scripts first.") + print(f"Found Category: {category.code} - '{category.name}' (ID: {category.id})") + + # Fetch/create SystemSetting to get exchange rate + setting, created = SystemSetting.objects.get_or_create(id=1, defaults={'usd_exchange_rate': 950.0}) + usd_rate = setting.usd_exchange_rate + print(f"System setting exchange rate is: {usd_rate}") + + # Prepare Mock Files + print("\nPreparing mock evidence files for upload...") + file1 = SimpleUploadedFile("receipt1.pdf", b"pdf content mock", content_type="application/pdf") + file2 = SimpleUploadedFile("receipt2.png", b"png content mock", content_type="image/png") + + # 2. Prepare POST data + print("\nStep 2: Preparing POST request data for '/gastos/nuevo/'...") + post_data = { + 'project': project.id, + 'category': category.id, + 'amount_clp': '2.500.000', + 'tax_type': TaxTypeEnum.IVA_19, + 'tax_amount_clp': '475.000', # 19% of 2.500.000 + 'total_amount_clp': '2.975.000', # 2.500.000 + 475.000 + 'currency': CurrencyEnum.CLP, + 'responsible_person': admin_user.id, + 'date': '2026-08-21', + 'description': 'Servicios de consultoría de prototipado rápido y PCBs', + 'new_vendor_rut': '77.888.999-5', + 'new_vendor_name': 'CONEXIONES SPA', + 'toggle-new-vendor': 'on', + 'region': 'Región Metropolitana', + 'comuna': 'Santiago', + 'receipt': [file1, file2], + } + + # Clean existing data to avoid conflicts and guarantee clean assertion + print("Cleaning any existing vendor/expense with target RUT to avoid duplication conflicts...") + Vendor.objects.filter(rut='77.888.999-5').delete() + + # 3. Execute POST request + print("\nStep 3: Executing POST request to '/gastos/nuevo/'...") + response = client.post('/gastos/nuevo/', post_data, follow=False) + + # 4. Verify results + print("\nStep 4: Verifying results...") + + # 4.1 Response Status and Redirect + status_code = response.status_code + location = response.get('Location', '') + print(f" [Assertion 1] Response Status Code: {status_code} (Expected: 302)") + print(f" [Assertion 1] Redirect Location: {location} (Expected: /gastos/)") + assert status_code == 302, f"Expected status code 302, got {status_code}" + assert '/gastos/' in location, f"Expected redirect to '/gastos/', got '{location}'" + print(" => Response redirect validation: PASSED") + + # 4.2 New Vendor creation + vendor = Vendor.objects.filter(rut='77.888.999-5').first() + assert vendor is not None, "Vendor was not created!" + print(f" [Assertion 2] Vendor Created: RUT={vendor.rut}, Name='{vendor.name}' (Expected: 'CONEXIONES SPA')") + assert vendor.name == 'CONEXIONES SPA', f"Expected name 'CONEXIONES SPA', got '{vendor.name}'" + print(" => Vendor DB validation: PASSED") + + # 4.3 New Expense creation + expense = Expense.objects.filter(vendor=vendor, date='2026-08-21').first() + assert expense is not None, "Expense was not created!" + print(f" [Assertion 3] Expense Created with net amount={expense.amount_clp} CLP (Expected: 2500000.00 CLP)") + assert expense.amount_clp == Decimal('2500000.00'), f"Expected net amount 2500000.00, got {expense.amount_clp}" + print(" => Expense DB amount validation: PASSED") + + # 4.4 Associated Document verification + documents = list(expense.documents.all()) + print(f" [Assertion 4] Associated Documents Count: {len(documents)} (Expected: 2)") + assert len(documents) == 2, f"Expected 2 documents, found {len(documents)}" + filenames = [os.path.basename(doc.file.name) for doc in documents] + print(f" [Assertion 4] Document filenames in DB: {filenames} (Expected containing 'receipt1.pdf' and 'receipt2.png')") + assert any("receipt1" in fn and fn.endswith(".pdf") for fn in filenames), "receipt1.pdf not found in documents" + assert any("receipt2" in fn and fn.endswith(".png") for fn in filenames), "receipt2.png not found in documents" + print(" => Documents validation: PASSED") + + # 4.5 USD conversion calculation + expected_usd = Decimal('2500000.00') / usd_rate + print(f" [Assertion 5] Expense USD Amount in DB: {expense.amount_usd} (Expected: {expected_usd})") + assert abs(expense.amount_usd - expected_usd) < Decimal('0.01'), f"USD conversion mismatch: DB={expense.amount_usd}, Expected={expected_usd}" + print(" => USD exchange rate conversion validation: PASSED") + + # 5. Test bank transfer export + print("\nStep 5: Testing 'export_bank_transfer_csv' endpoint...") + # Change expense status to SOLICITADO_FINANZAS to make sure it is exported + expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS + expense.save() + + export_response = client.get('/gastos/exportar/transferencias/') + print(f" [Assertion 6] Export Status Code: {export_response.status_code} (Expected: 200)") + assert export_response.status_code == 200, f"Expected 200, got {export_response.status_code}" + + # Decode using utf-8-sig to automatically handle BOM if present, or manually assert it + content_raw = export_response.content + assert content_raw.startswith(b'\xef\xbb\xbf'), "BOM bytes not found at the start of CSV!" + content_str = content_raw.decode('utf-8') + assert 'RUT Proveedor' in content_str, "CSV header mismatch!" + assert 'CONEXIONES SPA' in content_str, "CONEXIONES SPA should be present in the export!" + print(" => Bank Transfer CSV validation: PASSED") + + print("\n" + "=" * 60) + print("🎉 ALL INTEGRATION VALIDATION STEPS PASSED SUCCESSFULLY! 🎉") + print("=" * 60) + +if __name__ == '__main__': + try: + run_test() + except Exception as e: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/templates/expenses/accounts_payable.html b/templates/expenses/accounts_payable.html index 0c4aa1c..c4c626e 100644 --- a/templates/expenses/accounts_payable.html +++ b/templates/expenses/accounts_payable.html @@ -2,11 +2,18 @@ {% load humanize %} {% block content %} -
Consolidado de deudas por proveedor en estado Solicitado a Finanzas.
Arrastre sus boletas/facturas aquí o haga clic para buscar
+ Formatos aceptados: PDF, PNG, JPG, JPEG (Múltiple) +