FEAT: correccion IVA y Boletas, filtro categorias, vista previa evidencia y proveedor de servicios
This commit is contained in:
+12
-5
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;">×</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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user