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']