252 lines
14 KiB
Python
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']
|