FEAT: correccion IVA y Boletas, filtro categorias, vista previa evidencia y proveedor de servicios

This commit is contained in:
Nachou-u
2026-09-09 14:35:10 -03:00
parent acb3a1eaad
commit 522d5337f4
8 changed files with 112 additions and 13 deletions
+12 -5
View File
@@ -137,6 +137,7 @@ class ExpenseForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False) self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False)
self.fields['category'].queryset = Category.objects.filter(children__isnull=True)
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
try: try:
vlog = self.instance.vehicle_log vlog = self.instance.vehicle_log
@@ -218,10 +219,11 @@ class ExpenseForm(forms.ModelForm):
raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.") raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.")
# Recalculate taxes via SIIService # Recalculate taxes via SIIService
amount = cleaned_data.get('amount_clp') amount_clp = cleaned_data.get('amount_clp')
tax_type = cleaned_data.get('tax_type') tax_type = cleaned_data.get('tax_type')
if amount and tax_type: if amount_clp and amount_clp > 0 and tax_type:
tax_res = SIIService.calculate_taxes(amount, tax_type) tax_res = SIIService.calculate_taxes(amount_clp, tax_type)
cleaned_data['amount_clp'] = tax_res['net_amount']
cleaned_data['tax_amount_clp'] = tax_res['tax_amount'] cleaned_data['tax_amount_clp'] = tax_res['tax_amount']
cleaned_data['total_amount_clp'] = tax_res['total_amount'] cleaned_data['total_amount_clp'] = tax_res['total_amount']
@@ -229,7 +231,7 @@ class ExpenseForm(forms.ModelForm):
vendor = cleaned_data.get('vendor') vendor = cleaned_data.get('vendor')
doc_num = cleaned_data.get('document_number') doc_num = cleaned_data.get('document_number')
date_val = cleaned_data.get('date') date_val = cleaned_data.get('date')
if amount and (vendor or doc_num): if amount_clp and (vendor or doc_num):
duplicates = SIIService.check_duplicate_expense( duplicates = SIIService.check_duplicate_expense(
expense_model=Expense, expense_model=Expense,
vendor_id=vendor.id if vendor else None, vendor_id=vendor.id if vendor else None,
@@ -352,6 +354,10 @@ class ProjectForm(forms.ModelForm):
'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}), 'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = Category.objects.filter(children__isnull=True)
class ProjectPhaseForm(forms.ModelForm): class ProjectPhaseForm(forms.ModelForm):
due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada") due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada")
@@ -381,10 +387,11 @@ class CategoryForm(forms.ModelForm):
class VendorForm(forms.ModelForm): class VendorForm(forms.ModelForm):
class Meta: class Meta:
model = Vendor model = Vendor
fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms'] fields = ['rut', 'name', 'provider_type', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms']
widgets = { widgets = {
'rut': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}), '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'}), 'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required'}),
'provider_type': forms.Select(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}), 'email': forms.EmailInput(attrs={'class': 'form-control'}),
} }
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-09 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0012_alter_category_parent_alter_expense_project_and_more'),
]
operations = [
migrations.AddField(
model_name='vendor',
name='provider_type',
field=models.CharField(choices=[('BIENES', 'Proveedor de Bienes'), ('SERVICIOS', 'Proveedor de Servicios'), ('AMBOS', 'Bienes y Servicios')], default='BIENES', max_length=20, verbose_name='Clasificación'),
),
]
+6
View File
@@ -137,8 +137,14 @@ class Category(CatalogModel):
class Vendor(TimeStampedModel): class Vendor(TimeStampedModel):
PROVIDER_TYPE_CHOICES = [
('BIENES', 'Proveedor de Bienes'),
('SERVICIOS', 'Proveedor de Servicios'),
('AMBOS', 'Bienes y Servicios'),
]
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor") rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre") name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre")
provider_type = models.CharField(max_length=20, choices=PROVIDER_TYPE_CHOICES, default='BIENES', verbose_name="Clasificación")
business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial") business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial")
bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco") bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco")
+7 -5
View File
@@ -186,17 +186,19 @@ class SIIService:
'description': f'Retención Honorarios ({tasa_porcentaje}%) - Vía API Externa' 'description': f'Retención Honorarios ({tasa_porcentaje}%) - Vía API Externa'
} }
# 2. Fallback local (Bruto = Neto ingresado) # 2. Fallback local (Neto ingresado = Líquido)
# Retencion = Bruto * 0.1525 (año 2026) # Bruto = Líquido / (1 - 0.1525)
# Retencion = Bruto - Líquido
tasa = Decimal('0.1525') tasa = Decimal('0.1525')
tax_amount = (net_amount_clp * tasa).quantize(Decimal('1'), rounding=ROUND_HALF_UP) liquid_amount = net_amount_clp
liquid_amount = net_amount_clp - tax_amount bruto_amount = (liquid_amount / (Decimal('1') - tasa)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
tax_amount = bruto_amount - liquid_amount
return { return {
'tax_type': 'HONORARIOS_13_75', 'tax_type': 'HONORARIOS_13_75',
'net_amount': liquid_amount, 'net_amount': liquid_amount,
'tax_rate_percent': Decimal('15.25'), 'tax_rate_percent': Decimal('15.25'),
'tax_amount': tax_amount, 'tax_amount': tax_amount,
'total_amount': net_amount_clp, 'total_amount': bruto_amount,
'description': 'Retención Boleta de Honorarios 15.25%' 'description': 'Retención Boleta de Honorarios 15.25%'
} }
else: else:
+38
View File
@@ -0,0 +1,38 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from expenses.models import Category
from procurement.models import ItemCatalog
# 1. Update Project Categories to exactly 1201-1207
print("Actualizando Categorías a la serie 1200...")
Category.objects.filter(code__in=['3101', '3102', '3103', '3104', '3105', '3106', '3107']).delete()
required_codes = {
'1201': 'Investigación Básica',
'1202': 'Investigación Aplicada',
'1203': 'Desarrollo Experimental',
'1204': 'Prototipado',
'1205': 'Pruebas de Concepto (PoC)',
'1206': 'Gestión de Proyectos',
'1207': 'Otros Servicios'
}
parent, _ = Category.objects.get_or_create(code='1200', defaults={'name': 'Portafolio de Proyectos'})
for code, name in required_codes.items():
cat, created = Category.objects.get_or_create(code=code, defaults={'name': name, 'parent': parent})
if not created:
cat.name = name
cat.parent = parent
cat.save()
# 3. Delete 'actv' from ItemCatalog in Procurement
print("Eliminando ítem 'actv' del catálogo...")
items_to_delete = ItemCatalog.objects.filter(code__iexact='actv')
count = items_to_delete.count()
items_to_delete.delete()
print(f"Se eliminaron {count} ítems 'actv'.")
print("Migración de datos completada.")
+2 -1
View File
@@ -154,7 +154,8 @@ class PurchaseOrder(models.Model):
def recalculate_totals(self): def recalculate_totals(self):
neto = sum(item.subtotal_net for item in self.items.all()) neto = sum(item.subtotal_net for item in self.items.all())
iva = (neto * Decimal('0.19')).quantize(Decimal('1')) iva_rate = Decimal(str(self.tax_rate or '19.00')) / Decimal('100.0')
iva = (neto * iva_rate).quantize(Decimal('1'))
total = neto + iva total = neto + iva
self.net_amount_clp = neto self.net_amount_clp = neto
self.tax_amount_clp = iva self.tax_amount_clp = iva
+24
View File
@@ -167,5 +167,29 @@
}); });
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<!-- Modal Vista Previa -->
<div id="previewModal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; overflow:auto; background-color:rgba(0,0,0,0.8); align-items:center; justify-content:center;">
<div style="background:#fff; margin:auto; padding:20px; border-radius:8px; width:80%; max-width:800px; max-height:90vh; display:flex; flex-direction:column; position:relative;">
<span onclick="closePreview()" style="position:absolute; right:15px; top:10px; font-size:24px; cursor:pointer; font-weight:bold;">&times;</span>
<h3 id="previewTitle" style="margin-top:0;">Vista Previa</h3>
<div id="previewContent" style="flex:1; overflow:hidden; display:flex; justify-content:center; align-items:center; min-height:400px;"></div>
</div>
</div>
<script>
function openPreview(url, title) {
document.getElementById("previewTitle").innerText = "Evidencia: " + title;
let contentDiv = document.getElementById("previewContent");
if(url.toLowerCase().endsWith(".pdf")) {
contentDiv.innerHTML = "<iframe src=\"" + url + "\" style=\"width:100%; height:100%; min-height:600px; border:none;\"></iframe>";
} else {
contentDiv.innerHTML = "<img src=\"" + url + "\" style=\"max-width:100%; max-height:600px; object-fit:contain;\">";
}
document.getElementById("previewModal").style.display = "flex";
}
function closePreview() {
document.getElementById("previewModal").style.display = "none";
document.getElementById("previewContent").innerHTML = "";
}
</script>
</body> </body>
</html> </html>
+5 -2
View File
@@ -14,8 +14,11 @@
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;"> <div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %} {% for doc in expense.documents.all %}
<div style="display:inline-flex; align-items:center; gap:0.3rem;"> <div style="display:inline-flex; align-items:center; gap:0.3rem;">
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}"> <a href="javascript:void(0);" onclick="openPreview('{{ doc.file.url }}', '{{ doc.title|default:doc.file.name|escapejs }}')" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="Ver Evidencia">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }} <i data-lucide="eye" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
</a>
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-gray);" title="Descargar">
<i data-lucide="download" style="width:10px;"></i>
</a> </a>
</div> </div>
{% endfor %} {% endfor %}