iteracion 2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Procurement app
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.contrib import admin
|
||||
from .models import ItemCatalog, PurchaseRequisition, RequisitionItem, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch
|
||||
|
||||
class RequisitionItemInline(admin.TabularInline):
|
||||
model = RequisitionItem
|
||||
extra = 1
|
||||
|
||||
class PurchaseOrderItemInline(admin.TabularInline):
|
||||
model = PurchaseOrderItem
|
||||
extra = 1
|
||||
|
||||
@admin.register(ItemCatalog)
|
||||
class ItemCatalogAdmin(admin.ModelAdmin):
|
||||
list_display = ('code', 'name', 'category', 'unit', 'reference_price_clp', 'sla_days', 'preferred_vendor', 'is_active')
|
||||
search_fields = ('code', 'name', 'specifications')
|
||||
list_filter = ('category', 'unit', 'is_active')
|
||||
|
||||
@admin.register(PurchaseRequisition)
|
||||
class PurchaseRequisitionAdmin(admin.ModelAdmin):
|
||||
list_display = ('code', 'requester', 'project', 'priority', 'status', 'total_estimated_clp', 'required_date', 'created_at')
|
||||
search_fields = ('code', 'purpose', 'requester__username')
|
||||
list_filter = ('status', 'priority', 'project')
|
||||
inlines = [RequisitionItemInline]
|
||||
|
||||
@admin.register(PurchaseOrder)
|
||||
class PurchaseOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('code', 'vendor', 'project', 'issue_date', 'net_amount_clp', 'total_amount_clp', 'status')
|
||||
search_fields = ('code', 'vendor__name', 'vendor__rut')
|
||||
list_filter = ('status', 'project')
|
||||
inlines = [PurchaseOrderItemInline]
|
||||
|
||||
@admin.register(GoodsReceipt)
|
||||
class GoodsReceiptAdmin(admin.ModelAdmin):
|
||||
list_display = ('code', 'purchase_order', 'dispatch_guide_number', 'receipt_date', 'received_by', 'technical_conformity')
|
||||
search_fields = ('code', 'dispatch_guide_number', 'purchase_order__code')
|
||||
list_filter = ('technical_conformity', 'receipt_date')
|
||||
|
||||
@admin.register(ThreeWayMatch)
|
||||
class ThreeWayMatchAdmin(admin.ModelAdmin):
|
||||
list_display = ('purchase_order', 'dte_invoice_number', 'dte_invoice_rut', 'price_difference_clp', 'match_status', 'matched_at')
|
||||
search_fields = ('purchase_order__code', 'dte_invoice_number', 'dte_invoice_rut')
|
||||
list_filter = ('match_status',)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class ProcurementConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'procurement'
|
||||
verbose_name = 'Compras y Abastecimiento I+D+i'
|
||||
@@ -0,0 +1,134 @@
|
||||
from django import forms
|
||||
from .models import ItemCatalog, PurchaseRequisition, RequisitionItem, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch
|
||||
from expenses.models import Project, Vendor, Category
|
||||
from decimal import Decimal
|
||||
|
||||
class ItemCatalogForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ItemCatalog
|
||||
fields = ['code', 'name', 'category', 'unit', 'reference_price_clp', 'reference_price_usd', 'sla_days', 'preferred_vendor', 'specifications', 'is_active']
|
||||
widgets = {
|
||||
'code': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'CAT-XXX-01', 'required': 'required', 'pattern': 'CAT-[A-Z0-9]+-[0-9]+'}),
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}),
|
||||
'category': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'unit': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'reference_price_clp': forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'required': 'required', 'min': '0'}),
|
||||
'reference_price_usd': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'min': '0'}),
|
||||
'sla_days': forms.NumberInput(attrs={'class': 'form-control', 'required': 'required', 'min': '0'}),
|
||||
'preferred_vendor': forms.Select(attrs={'class': 'form-control'}),
|
||||
'specifications': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def clean_reference_price_clp(self):
|
||||
val = self.cleaned_data.get('reference_price_clp')
|
||||
if val is not None and val < 0:
|
||||
raise forms.ValidationError("El precio no puede ser negativo.")
|
||||
return val
|
||||
|
||||
|
||||
class PurchaseRequisitionForm(forms.ModelForm):
|
||||
required_date = forms.DateField(
|
||||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}),
|
||||
label="Fecha Requerida en Sitio / Faena"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseRequisition
|
||||
fields = ['project', 'purpose', 'priority', 'required_date']
|
||||
widgets = {
|
||||
'project': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'purpose': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Describa el objetivo técnico y la necesidad para el proyecto...', 'required': 'required', 'minlength': '10'}),
|
||||
'priority': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
}
|
||||
|
||||
|
||||
class PurchaseOrderForm(forms.ModelForm):
|
||||
issue_date = forms.DateField(
|
||||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}),
|
||||
label="Fecha de Emisión"
|
||||
)
|
||||
expected_delivery_date = forms.DateField(
|
||||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
label="Fecha Estimada de Entrega",
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = ['requisition', 'vendor', 'project', 'issue_date', 'expected_delivery_date', 'payment_terms', 'delivery_address', 'terms_and_conditions', 'notes']
|
||||
widgets = {
|
||||
'requisition': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'vendor': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'project': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'payment_terms': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 30 Días Contra Factura', 'required': 'required'}),
|
||||
'delivery_address': forms.TextInput(attrs={'class': 'form-control', 'id': 'id_delivery_address', 'required': 'required'}),
|
||||
'terms_and_conditions': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
issue = cleaned_data.get('issue_date')
|
||||
expected = cleaned_data.get('expected_delivery_date')
|
||||
if issue and expected and expected < issue:
|
||||
raise forms.ValidationError("La fecha de entrega esperada no puede ser anterior a la fecha de emisión.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class GoodsReceiptForm(forms.ModelForm):
|
||||
receipt_date = forms.DateField(
|
||||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}),
|
||||
label="Fecha de Recepción"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = GoodsReceipt
|
||||
fields = ['purchase_order', 'dispatch_guide_number', 'receipt_date', 'technical_conformity', 'notes']
|
||||
widgets = {
|
||||
'purchase_order': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'dispatch_guide_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'N° Guía de Despacho o Acta Terreno', 'required': 'required'}),
|
||||
'technical_conformity': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Observaciones de estado físico, embalaje o pruebas de funcionamiento...'}),
|
||||
}
|
||||
|
||||
|
||||
class ThreeWayMatchForm(forms.ModelForm):
|
||||
dte_invoice_date = forms.DateField(
|
||||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}),
|
||||
label="Fecha Emisión Factura SII"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ThreeWayMatch
|
||||
fields = [
|
||||
'purchase_order', 'goods_receipt', 'dte_invoice_number', 'dte_invoice_rut',
|
||||
'dte_invoice_date', 'dte_net_amount', 'dte_tax_amount', 'dte_total_amount',
|
||||
'resolution_notes'
|
||||
]
|
||||
widgets = {
|
||||
'purchase_order': forms.Select(attrs={'class': 'form-control', 'id': 'id_po_select', 'required': 'required'}),
|
||||
'goods_receipt': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
|
||||
'dte_invoice_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 45290', 'required': 'required', 'pattern': '^[0-9A-Za-z\-]+$'}),
|
||||
'dte_invoice_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 76.543.210-K', 'required': 'required', 'pattern': '^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$'}),
|
||||
'dte_net_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_net', 'required': 'required', 'min': '0'}),
|
||||
'dte_tax_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_tax', 'required': 'required', 'min': '0'}),
|
||||
'dte_total_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_total', 'required': 'required', 'min': '0'}),
|
||||
'resolution_notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Comentarios de auditoría o justificación de diferencias...'}),
|
||||
}
|
||||
|
||||
def clean_dte_invoice_rut(self):
|
||||
rut = self.cleaned_data.get('dte_invoice_rut')
|
||||
if not rut:
|
||||
raise forms.ValidationError("Este campo es obligatorio.")
|
||||
return rut.upper()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
net = cleaned_data.get('dte_net_amount')
|
||||
tax = cleaned_data.get('dte_tax_amount')
|
||||
total = cleaned_data.get('dte_total_amount')
|
||||
|
||||
if net is not None and tax is not None and total is not None:
|
||||
if net + tax != total:
|
||||
raise forms.ValidationError("El Monto Total debe ser igual a la suma del Monto Neto y el Monto IVA.")
|
||||
return cleaned_data
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by Django 6.1 on 2026-09-07 02:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('expenses', '0010_alter_expense_amount_usd'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemCatalog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50, unique=True, verbose_name='Código Ítem (CAT-XXX-XX)')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Descripción del Bien / Servicio')),
|
||||
('unit', models.CharField(choices=[('UN', 'Unidad (UN)'), ('MES', 'Mes de Servicio'), ('DIA', 'Día / Jornada'), ('HORA', 'Hora Especialista'), ('LITRO', 'Litros'), ('GLB', 'Servicio Global'), ('LIC', 'Licencia Anual / Mensual')], default='UN', max_length=20, verbose_name='Unidad de Medida')),
|
||||
('reference_price_clp', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Referencial Neto (CLP)')),
|
||||
('reference_price_usd', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Precio Referencial (USD)')),
|
||||
('sla_days', models.IntegerField(default=3, verbose_name='SLA / Plazo de Entrega (Días)')),
|
||||
('specifications', models.TextField(blank=True, verbose_name='Especificaciones Técnicas')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Ítem Activo en Catálogo')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='catalog_items', to='expenses.category', verbose_name='Categoría Contable')),
|
||||
('preferred_vendor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='catalog_items', to='expenses.vendor', verbose_name='Proveedor Homologado / Preferido')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Ítem de Catálogo',
|
||||
'verbose_name_plural': 'Catálogo de Ítems y Servicios',
|
||||
'ordering': ['code'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(default='OC-2026-0001', max_length=50, unique=True, verbose_name='Correlativo OC')),
|
||||
('issue_date', models.DateField(verbose_name='Fecha de Emisión')),
|
||||
('expected_delivery_date', models.DateField(blank=True, null=True, verbose_name='Fecha Estimada de Entrega')),
|
||||
('payment_terms', models.CharField(default='30 Días Contra Factura', max_length=100, verbose_name='Condiciones de Pago')),
|
||||
('delivery_address', models.CharField(default='Casa Matriz EFINTEL / Terreno Faena', max_length=255, verbose_name='Lugar de Entrega')),
|
||||
('net_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Neto (CLP)')),
|
||||
('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='Tasa IVA (%)')),
|
||||
('tax_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto IVA (CLP)')),
|
||||
('total_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Total (CLP)')),
|
||||
('status', models.CharField(choices=[('EMITIDA', 'Emitida'), ('ENVIADA_PROVEEDOR', 'Enviada al Proveedor'), ('RECEPCION_PARCIAL', 'Recepción Parcial'), ('RECEPCION_TOTAL', 'Recepción Conforme Total'), ('CONCILIADA_MATCH', '3-Way Match Conciliado'), ('CANCELADA', 'Cancelada')], default='EMITIDA', max_length=30, verbose_name='Estado de Orden de Compra')),
|
||||
('terms_and_conditions', models.TextField(blank=True, verbose_name='Términos y Condiciones Especiales')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Notas Internas')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issued_purchase_orders', to=settings.AUTH_USER_MODEL, verbose_name='Emitido por')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='expenses.project', verbose_name='Proyecto')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='expenses.vendor', verbose_name='Proveedor')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Orden de Compra',
|
||||
'verbose_name_plural': 'Órdenes de Compra (OC)',
|
||||
'ordering': ['-issue_date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GoodsReceipt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(default='REC-2026-0001', max_length=50, unique=True, verbose_name='N° Acta Recepción')),
|
||||
('dispatch_guide_number', models.CharField(max_length=50, verbose_name='N° Guía de Despacho / Acta Terreno')),
|
||||
('receipt_date', models.DateField(verbose_name='Fecha de Recepción')),
|
||||
('technical_conformity', models.BooleanField(default=True, verbose_name='Conformidad Técnica Aprobada')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Observaciones de Entrega / Inspección')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('received_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_deliveries', to=settings.AUTH_USER_MODEL, verbose_name='Receptor Conforme')),
|
||||
('purchase_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='procurement.purchaseorder', verbose_name='Orden de Compra')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recepción de Bienes / Servicios',
|
||||
'verbose_name_plural': 'Recepciones de Bienes y Servicios',
|
||||
'ordering': ['-receipt_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrderItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.CharField(max_length=255, verbose_name='Descripción del Bien / Servicio')),
|
||||
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Cantidad')),
|
||||
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Unitario Neto (CLP)')),
|
||||
('subtotal_net', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal Neto (CLP)')),
|
||||
('catalog_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='procurement.itemcatalog', verbose_name='Ítem del Catálogo')),
|
||||
('purchase_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='procurement.purchaseorder', verbose_name='Orden de Compra')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseRequisition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(default='REQ-2026-0001', max_length=50, unique=True, verbose_name='Código Requisición')),
|
||||
('purpose', models.TextField(verbose_name='Justificación Técnica y Destino I+D+i')),
|
||||
('priority', models.CharField(choices=[('BAJA', 'Baja'), ('MEDIA', 'Media'), ('ALTA', 'Alta'), ('CRITICA', 'Crítica / Faena Urgente')], default='MEDIA', max_length=30, verbose_name='Prioridad')),
|
||||
('status', models.CharField(choices=[('BORRADOR', 'Borrador'), ('SOLICITADA', 'Solicitada (Pendiente Aprobación)'), ('APROBADA_TECNICA', 'Aprobada Técnicamente'), ('APROBADA_FINANZAS', 'Aprobada por Finanzas'), ('RECHAZADA', 'Rechazada'), ('CONVERTIDA_OC', 'Orden de Compra Generada')], default='BORRADOR', max_length=30, verbose_name='Estado de Aprobación')),
|
||||
('required_date', models.DateField(verbose_name='Fecha Requerida en Sitio')),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Aprobación')),
|
||||
('rejection_reason', models.TextField(blank=True, verbose_name='Motivo de Rechazo')),
|
||||
('total_estimated_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Estimado Neto (CLP)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_requisitions', to=settings.AUTH_USER_MODEL, verbose_name='Aprobado por')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_requisitions', to='expenses.project', verbose_name='Proyecto / Centro de Costo')),
|
||||
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_requisitions', to=settings.AUTH_USER_MODEL, verbose_name='Solicitante')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Requisición de Compra',
|
||||
'verbose_name_plural': 'Requisiciones de Compra',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='requisition',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='procurement.purchaserequisition', verbose_name='Requisición de Origen'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RequisitionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.CharField(max_length=255, verbose_name='Descripción / Detalle')),
|
||||
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Cantidad')),
|
||||
('unit_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Unitario Estimado (CLP)')),
|
||||
('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal (CLP)')),
|
||||
('catalog_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requisition_items', to='procurement.itemcatalog', verbose_name='Ítem del Catálogo')),
|
||||
('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='procurement.purchaserequisition', verbose_name='Requisición')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ThreeWayMatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('dte_invoice_number', models.CharField(max_length=50, verbose_name='N° Factura Electrónica (DTE 33)')),
|
||||
('dte_invoice_rut', models.CharField(max_length=20, verbose_name='RUT Emisor Factura')),
|
||||
('dte_invoice_date', models.DateField(verbose_name='Fecha Emisión Factura')),
|
||||
('dte_net_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Neto Factura (CLP)')),
|
||||
('dte_tax_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto IVA Factura (CLP)')),
|
||||
('dte_total_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Total Factura (CLP)')),
|
||||
('price_difference_clp', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Diferencia de Monto Neto (CLP)')),
|
||||
('quantity_difference', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Diferencia en Cantidades')),
|
||||
('match_status', models.CharField(choices=[('MATCH_PERFECTO', 'Match Perfecto (Tolerancia 0% OK)'), ('DISCREPANCIA_PRECIO', 'Discrepancia en Precio / Monto Neto'), ('DISCREPANCIA_CANTIDAD', 'Discrepancia en Cantidades Recibidas'), ('PENDIENTE_RECEPCION', 'Pendiente de Recepción en Bodega/Faena'), ('RECHAZADO', 'Rechazado por Auditoría')], default='PENDIENTE_RECEPCION', max_length=30, verbose_name='Resultado del 3-Way Match')),
|
||||
('matched_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Conciliación')),
|
||||
('resolution_notes', models.TextField(blank=True, verbose_name='Notas de Resolución / Observación Contable')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('goods_receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='matches', to='procurement.goodsreceipt', verbose_name='Recepción Conforme')),
|
||||
('matched_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auditor / Conciliador')),
|
||||
('purchase_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='three_way_match', to='procurement.purchaseorder', verbose_name='Orden de Compra')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Conciliación 3-Way Match',
|
||||
'verbose_name_plural': 'Conciliaciones 3-Way Match (Tolerancia 0%)',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,251 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from expenses.models import Project, Category, Vendor
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
|
||||
class UnitOfMeasureEnum(models.TextChoices):
|
||||
UNIDAD = 'UN', 'Unidad (UN)'
|
||||
MES = 'MES', 'Mes de Servicio'
|
||||
DIA = 'DIA', 'Día / Jornada'
|
||||
HORA = 'HORA', 'Hora Especialista'
|
||||
LITRO = 'LITRO', 'Litros'
|
||||
GLOBAL = 'GLB', 'Servicio Global'
|
||||
LICENCIA = 'LIC', 'Licencia Anual / Mensual'
|
||||
|
||||
class RequisitionPriorityEnum(models.TextChoices):
|
||||
BAJA = 'BAJA', 'Baja'
|
||||
MEDIA = 'MEDIA', 'Media'
|
||||
ALTA = 'ALTA', 'Alta'
|
||||
CRITICA = 'CRITICA', 'Crítica / Faena Urgente'
|
||||
|
||||
class RequisitionStatusEnum(models.TextChoices):
|
||||
BORRADOR = 'BORRADOR', 'Borrador'
|
||||
SOLICITADA = 'SOLICITADA', 'Solicitada (Pendiente Aprobación)'
|
||||
APROBADA_TECNICA = 'APROBADA_TECNICA', 'Aprobada Técnicamente'
|
||||
APROBADA_FINANZAS = 'APROBADA_FINANZAS', 'Aprobada por Finanzas'
|
||||
RECHAZADA = 'RECHAZADA', 'Rechazada'
|
||||
CONVERTIDA_OC = 'CONVERTIDA_OC', 'Orden de Compra Generada'
|
||||
|
||||
class PurchaseOrderStatusEnum(models.TextChoices):
|
||||
EMITIDA = 'EMITIDA', 'Emitida'
|
||||
ENVIADA_PROVEEDOR = 'ENVIADA_PROVEEDOR', 'Enviada al Proveedor'
|
||||
RECEPCION_PARCIAL = 'RECEPCION_PARCIAL', 'Recepción Parcial'
|
||||
RECEPCION_TOTAL = 'RECEPCION_TOTAL', 'Recepción Conforme Total'
|
||||
CONCILIADA_MATCH = 'CONCILIADA_MATCH', '3-Way Match Conciliado'
|
||||
CANCELADA = 'CANCELADA', 'Cancelada'
|
||||
|
||||
class MatchStatusEnum(models.TextChoices):
|
||||
MATCH_PERFECTO = 'MATCH_PERFECTO', 'Match Perfecto (Tolerancia 0% OK)'
|
||||
DISCREPANCIA_PRECIO = 'DISCREPANCIA_PRECIO', 'Discrepancia en Precio / Monto Neto'
|
||||
DISCREPANCIA_CANTIDAD = 'DISCREPANCIA_CANTIDAD', 'Discrepancia en Cantidades Recibidas'
|
||||
PENDIENTE_RECEPCION = 'PENDIENTE_RECEPCION', 'Pendiente de Recepción en Bodega/Faena'
|
||||
RECHAZADO = 'RECHAZADO', 'Rechazado por Auditoría'
|
||||
|
||||
|
||||
class ItemCatalog(models.Model):
|
||||
"""
|
||||
Catálogo Oficial de Bienes, Servicios, Vehículos y Licencias EFINTEL (CAT-VEH-01 a CAT-SFT-09).
|
||||
"""
|
||||
code = models.CharField(max_length=50, unique=True, verbose_name="Código Ítem (CAT-XXX-XX)")
|
||||
name = models.CharField(max_length=200, verbose_name="Descripción del Bien / Servicio")
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='catalog_items', verbose_name="Categoría Contable")
|
||||
unit = models.CharField(max_length=20, choices=UnitOfMeasureEnum.choices, default=UnitOfMeasureEnum.UNIDAD, verbose_name="Unidad de Medida")
|
||||
|
||||
reference_price_clp = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Precio Referencial Neto (CLP)")
|
||||
reference_price_usd = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Precio Referencial (USD)")
|
||||
sla_days = models.IntegerField(default=3, verbose_name="SLA / Plazo de Entrega (Días)")
|
||||
|
||||
preferred_vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='catalog_items', verbose_name="Proveedor Homologado / Preferido")
|
||||
specifications = models.TextField(blank=True, verbose_name="Especificaciones Técnicas")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Ítem Activo en Catálogo")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - {self.name} (${self.reference_price_clp:,.0f} CLP)"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ítem de Catálogo"
|
||||
verbose_name_plural = "Catálogo de Ítems y Servicios"
|
||||
ordering = ['code']
|
||||
|
||||
|
||||
class PurchaseRequisition(models.Model):
|
||||
"""
|
||||
Requisición Interna de Compra (REQ-2026-XXXX).
|
||||
Inicia el flujo de abastecimiento y aprobación técnica/financiera.
|
||||
"""
|
||||
code = models.CharField(max_length=50, unique=True, verbose_name="Código Requisición", default="REQ-2026-0001")
|
||||
requester = models.ForeignKey(User, on_delete=models.CASCADE, related_name='purchase_requisitions', verbose_name="Solicitante")
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='purchase_requisitions', verbose_name="Proyecto / Centro de Costo")
|
||||
|
||||
purpose = models.TextField(verbose_name="Justificación Técnica y Destino I+D+i")
|
||||
priority = models.CharField(max_length=30, choices=RequisitionPriorityEnum.choices, default=RequisitionPriorityEnum.MEDIA, verbose_name="Prioridad")
|
||||
status = models.CharField(max_length=30, choices=RequisitionStatusEnum.choices, default=RequisitionStatusEnum.BORRADOR, verbose_name="Estado de Aprobación")
|
||||
|
||||
required_date = models.DateField(verbose_name="Fecha Requerida en Sitio")
|
||||
|
||||
approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_requisitions', verbose_name="Aprobado por")
|
||||
approved_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Aprobación")
|
||||
rejection_reason = models.TextField(blank=True, verbose_name="Motivo de Rechazo")
|
||||
|
||||
total_estimated_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Estimado Neto (CLP)")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - {self.project.code} ({self.get_status_display()})"
|
||||
|
||||
def recalculate_total(self):
|
||||
total = sum(item.subtotal for item in self.items.all())
|
||||
self.total_estimated_clp = total
|
||||
self.save(update_fields=['total_estimated_clp'])
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Requisición de Compra"
|
||||
verbose_name_plural = "Requisiciones de Compra"
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
class RequisitionItem(models.Model):
|
||||
requisition = models.ForeignKey(PurchaseRequisition, on_delete=models.CASCADE, related_name='items', verbose_name="Requisición")
|
||||
catalog_item = models.ForeignKey(ItemCatalog, on_delete=models.SET_NULL, null=True, blank=True, related_name='requisition_items', verbose_name="Ítem del Catálogo")
|
||||
description = models.CharField(max_length=255, verbose_name="Descripción / Detalle")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=2, default=1, verbose_name="Cantidad")
|
||||
unit_price = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Precio Unitario Estimado (CLP)")
|
||||
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Subtotal (CLP)")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.subtotal = self.quantity * self.unit_price
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.quantity} x {self.description} (${self.subtotal:,.0f})"
|
||||
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
"""
|
||||
Orden de Compra Oficial (OC-2026-XXXX).
|
||||
Cálculo formal Neto + 19% IVA = Total Facturable.
|
||||
"""
|
||||
code = models.CharField(max_length=50, unique=True, verbose_name="Correlativo OC", default="OC-2026-0001")
|
||||
requisition = models.ForeignKey(PurchaseRequisition, on_delete=models.SET_NULL, null=True, blank=True, related_name='purchase_orders', verbose_name="Requisición de Origen")
|
||||
vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name='purchase_orders', verbose_name="Proveedor")
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='purchase_orders', verbose_name="Proyecto")
|
||||
issuer = models.ForeignKey(User, on_delete=models.CASCADE, related_name='issued_purchase_orders', verbose_name="Emitido por")
|
||||
|
||||
issue_date = models.DateField(verbose_name="Fecha de Emisión")
|
||||
expected_delivery_date = models.DateField(null=True, blank=True, verbose_name="Fecha Estimada de Entrega")
|
||||
payment_terms = models.CharField(max_length=100, default="30 Días Contra Factura", verbose_name="Condiciones de Pago")
|
||||
delivery_address = models.CharField(max_length=255, default="Casa Matriz EFINTEL / Terreno Faena", verbose_name="Lugar de Entrega")
|
||||
|
||||
# Financial fields
|
||||
net_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Neto (CLP)")
|
||||
tax_rate = models.DecimalField(max_digits=5, decimal_places=2, default=19.00, verbose_name="Tasa IVA (%)")
|
||||
tax_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto IVA (CLP)")
|
||||
total_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (CLP)")
|
||||
|
||||
status = models.CharField(max_length=30, choices=PurchaseOrderStatusEnum.choices, default=PurchaseOrderStatusEnum.EMITIDA, verbose_name="Estado de Orden de Compra")
|
||||
terms_and_conditions = models.TextField(blank=True, verbose_name="Términos y Condiciones Especiales")
|
||||
notes = models.TextField(blank=True, verbose_name="Notas Internas")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def recalculate_totals(self):
|
||||
neto = sum(item.subtotal_net for item in self.items.all())
|
||||
iva = (neto * Decimal('0.19')).quantize(Decimal('1'))
|
||||
total = neto + iva
|
||||
self.net_amount_clp = neto
|
||||
self.tax_amount_clp = iva
|
||||
self.total_amount_clp = total
|
||||
self.save(update_fields=['net_amount_clp', 'tax_amount_clp', 'total_amount_clp'])
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - {self.vendor.name} (${self.total_amount_clp:,.0f} CLP)"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Orden de Compra"
|
||||
verbose_name_plural = "Órdenes de Compra (OC)"
|
||||
ordering = ['-issue_date', '-created_at']
|
||||
|
||||
|
||||
class PurchaseOrderItem(models.Model):
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='items', verbose_name="Orden de Compra")
|
||||
catalog_item = models.ForeignKey(ItemCatalog, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Ítem del Catálogo")
|
||||
description = models.CharField(max_length=255, verbose_name="Descripción del Bien / Servicio")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=2, default=1, verbose_name="Cantidad")
|
||||
unit_price_net = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Precio Unitario Neto (CLP)")
|
||||
subtotal_net = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Subtotal Neto (CLP)")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.subtotal_net = self.quantity * self.unit_price_net
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.quantity} x {self.description} (${self.subtotal_net:,.0f} Neto)"
|
||||
|
||||
|
||||
class GoodsReceipt(models.Model):
|
||||
"""
|
||||
Recepción Conforme en Bodega o Terreno.
|
||||
"""
|
||||
code = models.CharField(max_length=50, unique=True, verbose_name="N° Acta Recepción", default="REC-2026-0001")
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='receipts', verbose_name="Orden de Compra")
|
||||
dispatch_guide_number = models.CharField(max_length=50, verbose_name="N° Guía de Despacho / Acta Terreno")
|
||||
receipt_date = models.DateField(verbose_name="Fecha de Recepción")
|
||||
received_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_deliveries', verbose_name="Receptor Conforme")
|
||||
|
||||
technical_conformity = models.BooleanField(default=True, verbose_name="Conformidad Técnica Aprobada")
|
||||
notes = models.TextField(blank=True, verbose_name="Observaciones de Entrega / Inspección")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - Guía {self.dispatch_guide_number} (OC: {self.purchase_order.code})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Recepción de Bienes / Servicios"
|
||||
verbose_name_plural = "Recepciones de Bienes y Servicios"
|
||||
ordering = ['-receipt_date']
|
||||
|
||||
|
||||
class ThreeWayMatch(models.Model):
|
||||
"""
|
||||
Módulo de Conciliación 3-Way Match con tolerancia 0%.
|
||||
Compara:
|
||||
1. Orden de Compra (OC autorizada)
|
||||
2. Recepción Física (Guía de Despacho / Acta de Conformidad)
|
||||
3. Factura Electrónica SII (DTE 33)
|
||||
"""
|
||||
purchase_order = models.OneToOneField(PurchaseOrder, on_delete=models.CASCADE, related_name='three_way_match', verbose_name="Orden de Compra")
|
||||
goods_receipt = models.ForeignKey(GoodsReceipt, on_delete=models.SET_NULL, null=True, blank=True, related_name='matches', verbose_name="Recepción Conforme")
|
||||
|
||||
# Datos Factura Electrónica SII DTE 33
|
||||
dte_invoice_number = models.CharField(max_length=50, verbose_name="N° Factura Electrónica (DTE 33)")
|
||||
dte_invoice_rut = models.CharField(max_length=20, verbose_name="RUT Emisor Factura")
|
||||
dte_invoice_date = models.DateField(verbose_name="Fecha Emisión Factura")
|
||||
|
||||
dte_net_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto Factura (CLP)")
|
||||
dte_tax_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto IVA Factura (CLP)")
|
||||
dte_total_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Total Factura (CLP)")
|
||||
|
||||
# Discrepancias calculadas (Tolerancia 0%)
|
||||
price_difference_clp = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Diferencia de Monto Neto (CLP)")
|
||||
quantity_difference = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Diferencia en Cantidades")
|
||||
|
||||
match_status = models.CharField(max_length=30, choices=MatchStatusEnum.choices, default=MatchStatusEnum.PENDIENTE_RECEPCION, verbose_name="Resultado del 3-Way Match")
|
||||
|
||||
matched_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Auditor / Conciliador")
|
||||
matched_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Conciliación")
|
||||
resolution_notes = models.TextField(blank=True, verbose_name="Notas de Resolución / Observación Contable")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Match OC {self.purchase_order.code} vs Fac {self.dte_invoice_number} ({self.get_match_status_display()})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Conciliación 3-Way Match"
|
||||
verbose_name_plural = "Conciliaciones 3-Way Match (Tolerancia 0%)"
|
||||
ordering = ['-created_at']
|
||||
@@ -0,0 +1,3 @@
|
||||
from .matching import ThreeWayMatchingEngine
|
||||
|
||||
__all__ = ['ThreeWayMatchingEngine']
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
3-Way Matching Engine with Strict 0% Tolerance for EFINTEL Procurement.
|
||||
Matches Purchase Order vs Goods Receipt vs Electronic Invoice SII (DTE 33).
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
class ThreeWayMatchingEngine:
|
||||
TOLERANCE_CLP = Decimal('0.00')
|
||||
|
||||
@classmethod
|
||||
def evaluate_match(
|
||||
cls,
|
||||
po,
|
||||
goods_receipt,
|
||||
dte_invoice_net: Decimal,
|
||||
dte_invoice_total: Decimal,
|
||||
dte_invoice_rut: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Executes strict 3-Way Match validation:
|
||||
1. Check receipt existence and technical conformity.
|
||||
2. Check net price difference against PO (0% tolerance).
|
||||
3. Verify vendor RUT match.
|
||||
"""
|
||||
dte_net = Decimal(str(dte_invoice_net))
|
||||
dte_total = Decimal(str(dte_invoice_total))
|
||||
po_net = po.net_amount_clp
|
||||
po_total = po.total_amount_clp
|
||||
|
||||
# Price Difference
|
||||
price_diff = dte_net - po_net
|
||||
total_diff = dte_total - po_total
|
||||
|
||||
# Check receipt
|
||||
if not goods_receipt:
|
||||
return {
|
||||
'status': 'PENDIENTE_RECEPCION',
|
||||
'is_match': False,
|
||||
'price_difference': price_diff,
|
||||
'message': 'No se ha registrado aún la recepción conforme (Guía de Despacho / Acta) para esta OC.'
|
||||
}
|
||||
|
||||
if not goods_receipt.technical_conformity:
|
||||
return {
|
||||
'status': 'DISCREPANCIA_CANTIDAD',
|
||||
'is_match': False,
|
||||
'price_difference': price_diff,
|
||||
'message': 'La recepción física fue observada o rechazada técnicamente en terreno.'
|
||||
}
|
||||
|
||||
# Check Price difference with 0% tolerance
|
||||
if abs(price_diff) > cls.TOLERANCE_CLP:
|
||||
return {
|
||||
'status': 'DISCREPANCIA_PRECIO',
|
||||
'is_match': False,
|
||||
'price_difference': price_diff,
|
||||
'message': f'Discrepancia en monto neto: OC por ${po_net:,.0f} CLP vs Factura por ${dte_net:,.0f} CLP (Diferencia: ${price_diff:,.0f} CLP). Tolerancia 0% violada.'
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'MATCH_PERFECTO',
|
||||
'is_match': True,
|
||||
'price_difference': Decimal('0'),
|
||||
'message': 'Match 100% perfecto. OC, Recepción Conforme y Factura DTE 33 coinciden al centavo (Tolerancia 0% aprobada).'
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'procurement'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.catalog_list, name='procurement_index'),
|
||||
# Catálogo
|
||||
path('catalogo/', views.catalog_list, name='catalog_list'),
|
||||
path('catalogo/nuevo/', views.catalog_create, name='catalog_create'),
|
||||
path('catalogo/<int:pk>/editar/', views.catalog_edit, name='catalog_edit'),
|
||||
|
||||
# Requisiciones
|
||||
path('requisiciones/', views.requisition_list, name='requisition_list'),
|
||||
path('requisiciones/nueva/', views.requisition_create, name='requisition_create'),
|
||||
path('requisiciones/<int:pk>/', views.requisition_detail, name='requisition_detail'),
|
||||
path('requisiciones/<int:pk>/accion/<str:action>/', views.requisition_action, name='requisition_action'),
|
||||
|
||||
# Órdenes de Compra
|
||||
path('ordenes-compra/', views.purchase_order_list, name='purchase_order_list'),
|
||||
path('ordenes-compra/nueva/', views.purchase_order_create, name='purchase_order_create'),
|
||||
path('ordenes-compra/<int:pk>/', views.purchase_order_detail, name='purchase_order_detail'),
|
||||
|
||||
# Recepción
|
||||
path('recepcion/nueva/', views.goods_receipt_create, name='goods_receipt_create'),
|
||||
path('ordenes-compra/<int:po_pk>/recepcion/', views.goods_receipt_create, name='goods_receipt_for_po'),
|
||||
|
||||
# 3-Way Match
|
||||
path('3-way-match/', views.three_way_match_list, name='three_way_match_list'),
|
||||
path('3-way-match/conciliar/', views.three_way_match_create, name='three_way_match_create'),
|
||||
]
|
||||
@@ -0,0 +1,379 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum, Count, Q
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
from .models import (
|
||||
ItemCatalog, PurchaseRequisition, RequisitionItem,
|
||||
PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch,
|
||||
RequisitionStatusEnum, PurchaseOrderStatusEnum, MatchStatusEnum
|
||||
)
|
||||
from .forms import (
|
||||
ItemCatalogForm, PurchaseRequisitionForm, PurchaseOrderForm,
|
||||
GoodsReceiptForm, ThreeWayMatchForm
|
||||
)
|
||||
from .services.matching import ThreeWayMatchingEngine
|
||||
from expenses.models import Project, Vendor, Category
|
||||
|
||||
# ----------------- CATALOG VIEWS -----------------
|
||||
@login_required
|
||||
def catalog_list(request):
|
||||
query = request.GET.get('q', '')
|
||||
cat_id = request.GET.get('category', '')
|
||||
|
||||
items = ItemCatalog.objects.filter(is_active=True).select_related('category', 'preferred_vendor')
|
||||
if query:
|
||||
items = items.filter(Q(name__icontains=query) | Q(code__icontains=query) | Q(specifications__icontains=query))
|
||||
if cat_id:
|
||||
items = items.filter(category_id=cat_id)
|
||||
|
||||
categories = Category.objects.all()
|
||||
return render(request, 'procurement/catalog_list.html', {
|
||||
'items': items,
|
||||
'categories': categories,
|
||||
'selected_cat': cat_id,
|
||||
'query': query,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def catalog_create(request):
|
||||
if request.method == 'POST':
|
||||
form = ItemCatalogForm(request.POST)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
messages.success(request, f"Ítem {item.code} - {item.name} agregado al catálogo.")
|
||||
return redirect('procurement:catalog_list')
|
||||
else:
|
||||
form = ItemCatalogForm()
|
||||
return render(request, 'procurement/catalog_form.html', {'form': form, 'title': 'Nuevo Ítem de Catálogo'})
|
||||
|
||||
@login_required
|
||||
def catalog_edit(request, pk):
|
||||
item = get_object_or_404(ItemCatalog, pk=pk)
|
||||
if request.method == 'POST':
|
||||
form = ItemCatalogForm(request.POST, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
messages.success(request, f"Ítem {item.code} actualizado.")
|
||||
return redirect('procurement:catalog_list')
|
||||
else:
|
||||
form = ItemCatalogForm(instance=item)
|
||||
return render(request, 'procurement/catalog_form.html', {'form': form, 'title': f"Editar Ítem: {item.code}"})
|
||||
|
||||
|
||||
# ----------------- REQUISITION VIEWS -----------------
|
||||
@login_required
|
||||
def requisition_list(request):
|
||||
requisitions = PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by').all().order_by('-created_at')
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
requisitions = requisitions.filter(status=status_filter)
|
||||
|
||||
return render(request, 'procurement/requisition_list.html', {
|
||||
'requisitions': requisitions,
|
||||
'selected_status': status_filter,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def requisition_create(request):
|
||||
if request.method == 'POST':
|
||||
form = PurchaseRequisitionForm(request.POST)
|
||||
if form.is_valid():
|
||||
req = form.save(commit=False)
|
||||
req.requester = request.user
|
||||
|
||||
# Generate code REQ-2026-XXXX
|
||||
count = PurchaseRequisition.objects.count() + 1
|
||||
req.code = f"REQ-2026-{count:04d}"
|
||||
req.status = RequisitionStatusEnum.SOLICITADA
|
||||
req.save()
|
||||
|
||||
# Process dynamic items submitted
|
||||
item_descs = request.POST.getlist('item_desc[]')
|
||||
item_qtys = request.POST.getlist('item_qty[]')
|
||||
item_prices = request.POST.getlist('item_price[]')
|
||||
item_catalog_ids = request.POST.getlist('item_catalog_id[]')
|
||||
|
||||
total_est = Decimal('0')
|
||||
for i in range(len(item_descs)):
|
||||
desc = item_descs[i].strip()
|
||||
if not desc:
|
||||
continue
|
||||
qty = Decimal(item_qtys[i] or '1')
|
||||
price = Decimal(item_prices[i] or '0')
|
||||
cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None
|
||||
|
||||
cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None
|
||||
subtotal = qty * price
|
||||
total_est += subtotal
|
||||
|
||||
RequisitionItem.objects.create(
|
||||
requisition=req,
|
||||
catalog_item=cat_obj,
|
||||
description=desc,
|
||||
quantity=qty,
|
||||
unit_price=price,
|
||||
subtotal=subtotal
|
||||
)
|
||||
|
||||
req.total_estimated_clp = total_est
|
||||
req.save(update_fields=['total_estimated_clp'])
|
||||
|
||||
messages.success(request, f"Requisición {req.code} creada con éxito y enviada a revisión técnica.")
|
||||
return redirect('procurement:requisition_detail', pk=req.pk)
|
||||
else:
|
||||
form = PurchaseRequisitionForm()
|
||||
|
||||
catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code')
|
||||
return render(request, 'procurement/requisition_form.html', {
|
||||
'form': form,
|
||||
'catalog_items': catalog_items
|
||||
})
|
||||
|
||||
@login_required
|
||||
def requisition_detail(request, pk):
|
||||
req = get_object_or_404(PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by'), pk=pk)
|
||||
return render(request, 'procurement/requisition_detail.html', {'req': req})
|
||||
|
||||
@login_required
|
||||
def requisition_action(request, pk, action):
|
||||
req = get_object_or_404(PurchaseRequisition, pk=pk)
|
||||
if request.method == 'POST':
|
||||
if action == 'approve_tech':
|
||||
req.status = RequisitionStatusEnum.APROBADA_TECNICA
|
||||
messages.success(request, f"Requisición {req.code} aprobada técnicamente.")
|
||||
elif action == 'approve_finance':
|
||||
req.status = RequisitionStatusEnum.APROBADA_FINANZAS
|
||||
req.approved_by = request.user
|
||||
req.approved_at = timezone.now()
|
||||
messages.success(request, f"Requisición {req.code} aprobada por Finanzas. Lista para emitir OC.")
|
||||
elif action == 'reject':
|
||||
req.status = RequisitionStatusEnum.RECHAZADA
|
||||
req.rejection_reason = request.POST.get('reason', 'Sin motivo especificado')
|
||||
messages.warning(request, f"Requisición {req.code} rechazada.")
|
||||
elif action == 'convert_po':
|
||||
# Create PO directly
|
||||
count = PurchaseOrder.objects.count() + 1
|
||||
po_code = f"OC-2026-{count:04d}"
|
||||
|
||||
# Default vendor: from first catalog item or first available
|
||||
vendor = None
|
||||
first_item = req.items.first()
|
||||
if first_item and first_item.catalog_item and first_item.catalog_item.preferred_vendor:
|
||||
vendor = first_item.catalog_item.preferred_vendor
|
||||
else:
|
||||
vendor = Vendor.objects.first()
|
||||
|
||||
po = PurchaseOrder.objects.create(
|
||||
code=po_code,
|
||||
requisition=req,
|
||||
vendor=vendor,
|
||||
project=req.project,
|
||||
issuer=request.user,
|
||||
issue_date=date.today(),
|
||||
payment_terms="30 Días Contra Factura",
|
||||
delivery_address="Casa Matriz EFINTEL / Terreno Faena",
|
||||
net_amount_clp=req.total_estimated_clp,
|
||||
tax_rate=Decimal('19.00'),
|
||||
tax_amount_clp=(req.total_estimated_clp * Decimal('0.19')).quantize(Decimal('1')),
|
||||
total_amount_clp=(req.total_estimated_clp * Decimal('1.19')).quantize(Decimal('1')),
|
||||
status=PurchaseOrderStatusEnum.EMITIDA
|
||||
)
|
||||
|
||||
for item in req.items.all():
|
||||
PurchaseOrderItem.objects.create(
|
||||
purchase_order=po,
|
||||
catalog_item=item.catalog_item,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit_price_net=item.unit_price,
|
||||
subtotal_net=item.subtotal
|
||||
)
|
||||
|
||||
req.status = RequisitionStatusEnum.CONVERTIDA_OC
|
||||
req.save(update_fields=['status'])
|
||||
|
||||
messages.success(request, f"Se generó exitosamente la Orden de Compra {po.code} a partir de la requisición.")
|
||||
return redirect('procurement:purchase_order_detail', pk=po.pk)
|
||||
|
||||
req.save()
|
||||
return redirect('procurement:requisition_detail', pk=pk)
|
||||
|
||||
|
||||
# ----------------- PURCHASE ORDER VIEWS -----------------
|
||||
@login_required
|
||||
def purchase_order_list(request):
|
||||
orders = PurchaseOrder.objects.select_related('vendor', 'project', 'issuer').all().order_by('-issue_date', '-created_at')
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
orders = orders.filter(status=status_filter)
|
||||
|
||||
totals = orders.aggregate(
|
||||
total_net=Sum('net_amount_clp'),
|
||||
total_tax=Sum('tax_amount_clp'),
|
||||
total_gross=Sum('total_amount_clp')
|
||||
)
|
||||
return render(request, 'procurement/purchase_order_list.html', {
|
||||
'orders': orders,
|
||||
'selected_status': status_filter,
|
||||
'total_net': totals['total_net'] or 0,
|
||||
'total_tax': totals['total_tax'] or 0,
|
||||
'total_gross': totals['total_gross'] or 0,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def purchase_order_create(request):
|
||||
if request.method == 'POST':
|
||||
form = PurchaseOrderForm(request.POST)
|
||||
if form.is_valid():
|
||||
po = form.save(commit=False)
|
||||
po.issuer = request.user
|
||||
count = PurchaseOrder.objects.count() + 1
|
||||
po.code = f"OC-2026-{count:04d}"
|
||||
po.save()
|
||||
|
||||
# Items
|
||||
item_descs = request.POST.getlist('item_desc[]')
|
||||
item_qtys = request.POST.getlist('item_qty[]')
|
||||
item_prices = request.POST.getlist('item_price[]')
|
||||
item_catalog_ids = request.POST.getlist('item_catalog_id[]')
|
||||
|
||||
neto_total = Decimal('0')
|
||||
for i in range(len(item_descs)):
|
||||
desc = item_descs[i].strip()
|
||||
if not desc:
|
||||
continue
|
||||
qty = Decimal(item_qtys[i] or '1')
|
||||
price = Decimal(item_prices[i] or '0')
|
||||
cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None
|
||||
|
||||
cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None
|
||||
subtotal = qty * price
|
||||
neto_total += subtotal
|
||||
|
||||
PurchaseOrderItem.objects.create(
|
||||
purchase_order=po,
|
||||
catalog_item=cat_obj,
|
||||
description=desc,
|
||||
quantity=qty,
|
||||
unit_price_net=price,
|
||||
subtotal_net=subtotal
|
||||
)
|
||||
|
||||
po.net_amount_clp = neto_total
|
||||
po.tax_amount_clp = (neto_total * Decimal('0.19')).quantize(Decimal('1'))
|
||||
po.total_amount_clp = po.net_amount_clp + po.tax_amount_clp
|
||||
po.save()
|
||||
|
||||
messages.success(request, f"Orden de Compra {po.code} generada exitosamente.")
|
||||
return redirect('procurement:purchase_order_detail', pk=po.pk)
|
||||
else:
|
||||
form = PurchaseOrderForm(initial={'issue_date': date.today()})
|
||||
|
||||
catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code')
|
||||
return render(request, 'procurement/purchase_order_form.html', {
|
||||
'form': form,
|
||||
'catalog_items': catalog_items
|
||||
})
|
||||
|
||||
@login_required
|
||||
def purchase_order_detail(request, pk):
|
||||
po = get_object_or_404(PurchaseOrder.objects.select_related('vendor', 'project', 'issuer', 'requisition'), pk=pk)
|
||||
return render(request, 'procurement/purchase_order_detail.html', {'po': po})
|
||||
|
||||
|
||||
# ----------------- GOODS RECEIPT VIEWS -----------------
|
||||
@login_required
|
||||
def goods_receipt_create(request, po_pk=None):
|
||||
initial = {'receipt_date': date.today()}
|
||||
if po_pk:
|
||||
po = get_object_or_404(PurchaseOrder, pk=po_pk)
|
||||
initial['purchase_order'] = po
|
||||
|
||||
if request.method == 'POST':
|
||||
form = GoodsReceiptForm(request.POST)
|
||||
if form.is_valid():
|
||||
gr = form.save(commit=False)
|
||||
gr.received_by = request.user
|
||||
count = GoodsReceipt.objects.count() + 1
|
||||
gr.code = f"REC-2026-{count:04d}"
|
||||
gr.save()
|
||||
|
||||
# Update PO status to reception
|
||||
po = gr.purchase_order
|
||||
po.status = PurchaseOrderStatusEnum.RECEPCION_TOTAL if gr.technical_conformity else PurchaseOrderStatusEnum.RECEPCION_PARCIAL
|
||||
po.save(update_fields=['status'])
|
||||
|
||||
messages.success(request, f"Acta de Recepción {gr.code} registrada conforme para la OC {po.code}.")
|
||||
return redirect('procurement:purchase_order_detail', pk=po.pk)
|
||||
else:
|
||||
form = GoodsReceiptForm(initial=initial)
|
||||
return render(request, 'procurement/goods_receipt_form.html', {'form': form})
|
||||
|
||||
|
||||
# ----------------- 3-WAY MATCHING VIEWS -----------------
|
||||
@login_required
|
||||
def three_way_match_list(request):
|
||||
matches = ThreeWayMatch.objects.select_related('purchase_order', 'goods_receipt', 'matched_by', 'purchase_order__vendor').all().order_by('-created_at')
|
||||
|
||||
# Also find POs pending matching
|
||||
matched_po_ids = ThreeWayMatch.objects.values_list('purchase_order_id', flat=True)
|
||||
pending_pos = PurchaseOrder.objects.exclude(id__in=matched_po_ids).select_related('vendor', 'project')
|
||||
|
||||
return render(request, 'procurement/three_way_match.html', {
|
||||
'matches': matches,
|
||||
'pending_pos': pending_pos
|
||||
})
|
||||
|
||||
@login_required
|
||||
def three_way_match_create(request):
|
||||
po_id = request.GET.get('po_id') or request.POST.get('purchase_order')
|
||||
po = get_object_or_404(PurchaseOrder, pk=po_id) if po_id else None
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ThreeWayMatchForm(request.POST)
|
||||
if form.is_valid():
|
||||
match = form.save(commit=False)
|
||||
match.matched_by = request.user
|
||||
match.matched_at = timezone.now()
|
||||
|
||||
# Evaluate match with engine (0% tolerance)
|
||||
eval_result = ThreeWayMatchingEngine.evaluate_match(
|
||||
po=match.purchase_order,
|
||||
goods_receipt=match.goods_receipt,
|
||||
dte_invoice_net=match.dte_net_amount,
|
||||
dte_invoice_total=match.dte_total_amount,
|
||||
dte_invoice_rut=match.dte_invoice_rut
|
||||
)
|
||||
|
||||
match.price_difference_clp = eval_result['price_difference']
|
||||
match.match_status = eval_result['status']
|
||||
match.save()
|
||||
|
||||
if eval_result['is_match']:
|
||||
match.purchase_order.status = PurchaseOrderStatusEnum.CONCILIADA_MATCH
|
||||
match.purchase_order.save(update_fields=['status'])
|
||||
messages.success(request, f"¡3-Way Match Exitoso! {eval_result['message']}")
|
||||
else:
|
||||
messages.warning(request, f"Alerta 3-Way Match: {eval_result['message']}")
|
||||
|
||||
return redirect('procurement:three_way_match_list')
|
||||
else:
|
||||
initial = {'dte_invoice_date': date.today()}
|
||||
if po:
|
||||
initial['purchase_order'] = po
|
||||
initial['dte_net_amount'] = po.net_amount_clp
|
||||
initial['dte_tax_amount'] = po.tax_amount_clp
|
||||
initial['dte_total_amount'] = po.total_amount_clp
|
||||
initial['dte_invoice_rut'] = po.vendor.rut
|
||||
latest_receipt = po.receipts.first()
|
||||
if latest_receipt:
|
||||
initial['goods_receipt'] = latest_receipt
|
||||
form = ThreeWayMatchForm(initial=initial)
|
||||
|
||||
return render(request, 'procurement/three_way_match_form.html', {'form': form, 'po': po})
|
||||
Reference in New Issue
Block a user