iteracion 2

This commit is contained in:
Nachou-u
2026-09-07 18:44:32 -03:00
parent a103a47ae6
commit 09a3f6fb37
93 changed files with 7394 additions and 349 deletions
+1
View File
@@ -0,0 +1 @@
# Procurement app
+42
View File
@@ -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',)
+6
View File
@@ -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'
+134
View File
@@ -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
+166
View File
@@ -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'],
},
),
]
View File
+251
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from .matching import ThreeWayMatchingEngine
__all__ = ['ThreeWayMatchingEngine']
+68
View File
@@ -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).'
}
+31
View File
@@ -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'),
]
+379
View File
@@ -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})