Files
erp-efintel-2026-portainer/procurement/models.py
T

252 lines
14 KiB
Python

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.PROTECT, related_name='purchase_requisitions', verbose_name="Solicitante")
project = models.ForeignKey(Project, on_delete=models.PROTECT, 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.PROTECT, related_name='purchase_orders', verbose_name="Proveedor")
project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name="Proyecto")
issuer = models.ForeignKey(User, on_delete=models.PROTECT, 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.PROTECT, 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.PROTECT, 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.PROTECT, 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']