iteracion 2
This commit is contained in:
@@ -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']
|
||||
Reference in New Issue
Block a user