from django.db import models from django.contrib.auth.models import User from decimal import Decimal import uuid # Enums class CurrencyEnum(models.TextChoices): CLP = 'CLP', 'Pesos Chilenos' USD = 'USD', 'Dólares Estadounidenses' class TaxTypeEnum(models.TextChoices): EXENTO = 'EXENTO', 'Exento / No Afecto' IVA_19 = 'IVA_19', 'Afecto a IVA (19%)' HONORARIOS = 'HONORARIOS_13_75', 'Boleta de Honorarios (13.75%)' class ExpenseTypeEnum(models.TextChoices): COSTO_DIRECTO = 'COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto' GASTO_OPERACIONAL = 'GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead' class ApprovalStatusEnum(models.TextChoices): EN_REVISION = 'EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)' SOLICITADO_FINANZAS = 'SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)' LIBERADO_PAGADO = 'LIBERADO_PAGADO', 'Pago Liberado (Paso 3)' OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado' class ProjectStatusEnum(models.TextChoices): BORRADOR = 'BORRADOR', 'Borrador / En Diseño' ACTIVO = 'ACTIVO', 'Activo' PAUSADA = 'PAUSADA', 'En Pausa' TERMINADO = 'TERMINADO', 'Terminado' class VehicleOwnershipEnum(models.TextChoices): ARRENDADO = 'ARRENDADO', 'Arrendado (4x4, etc.)' PROPIO = 'PROPIO', 'Propio (Reembolso Kilometraje)' BUS = 'BUS', 'Bus' UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi' class ExpenseReportStatusEnum(models.TextChoices): BORRADOR = 'BORRADOR', 'Borrador' ENVIADO = 'ENVIADO', 'Enviado para Aprobación' APROBADO = 'APROBADO', 'Aprobado' REEMBOLSADO = 'REEMBOLSADO', 'Reembolsado / Pagado' OBSERVADO = 'OBSERVADO', 'Observado / Rechazado' from django.core.exceptions import ValidationError class TimeStampedModel(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación") updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") class Meta: abstract = True class CatalogModel(TimeStampedModel): code = models.CharField(max_length=50, unique=True, verbose_name="Código") name = models.CharField(max_length=200, verbose_name="Nombre") description = models.TextField(blank=True, verbose_name="Descripción") class Meta: abstract = True class Project(CatalogModel): manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado / Director") parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Portafolio / Proyecto Padre") assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)") assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)") actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)") payment_method = models.CharField(max_length=150, default="Contrato por Fases / Hitos", verbose_name="Forma de Pago") total_income_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Ingresos Totales (CLP)") type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro") status = models.CharField(max_length=50, choices=ProjectStatusEnum.choices, default=ProjectStatusEnum.ACTIVO, verbose_name="Estado de Proyecto") category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name="Categoría del Proyecto") created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_projects', verbose_name="Creado por") def __str__(self): return f"{self.code} - {self.name}" @property def total_expenses_clp(self): if hasattr(self, 'total_expenses'): return self.total_expenses or Decimal('0.00') return self.expenses.aggregate(total=models.Sum('amount_clp'))['total'] or Decimal('0.00') @property def balance_clp(self): return self.total_income_clp - self.total_expenses_clp @property def budget_balance_clp(self): return self.assigned_budget_clp - self.total_expenses_clp class Meta: verbose_name = "Proyecto / Centro de Costo" verbose_name_plural = "Proyectos y Centros" class ProjectPhase(TimeStampedModel): project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='phases', verbose_name="Proyecto") phase_number = models.IntegerField(default=1, verbose_name="N° Fase / Hito") name = models.CharField(max_length=150, verbose_name="Nombre de la Fase / Hito") description = models.TextField(blank=True, verbose_name="Descripción de Entregables") planned_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Planificado / Pactado (CLP)") income_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Ingresado / Cobrado (CLP)") expense_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Egresado / Gastado (CLP)") due_date = models.DateField(null=True, blank=True, verbose_name="Fecha Pactada") is_completed = models.BooleanField(default=False, verbose_name="Fase Completada") class Meta: ordering = ['phase_number'] verbose_name = "Fase / Hito de Pago de Proyecto" verbose_name_plural = "Fases / Hitos de Pago de Proyecto" @property def balance_clp(self): return self.income_amount_clp - self.expense_amount_clp def __str__(self): return f"{self.project.code} - Fase {self.phase_number}: {self.name}" class Category(CatalogModel): parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Categoría Padre") def __str__(self): return f"{self.code}: {self.name}" class Meta: verbose_name = "Categoría" verbose_name_plural = "Categorías" class Vendor(TimeStampedModel): rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor") name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre") business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial") bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco") account_type = models.CharField(max_length=50, blank=True, null=True, verbose_name="Tipo de Cuenta") account_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Número de Cuenta") email = models.EmailField(blank=True, null=True, verbose_name="Correo de Pago/Facturación") payment_terms = models.IntegerField(default=30, verbose_name="Plazo de Pago (Días)") def __str__(self): return f"{self.rut} - {self.name}" class Meta: verbose_name = "Proveedor" verbose_name_plural = "Proveedores" ordering = ['name'] class ExpenseReport(models.Model): report_code = models.CharField(max_length=50, unique=True, verbose_name="Código Rendición", default="EXP-REP-001") title = models.CharField(max_length=250, verbose_name="Título del Informe / Viaje") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Responsable Rendidor") project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Proyecto Imputado") start_date = models.DateField(verbose_name="Fecha de Inicio") end_date = models.DateField(verbose_name="Fecha de Término") purpose = models.TextField(verbose_name="Propósito / Justificación de la Comisión") status = models.CharField(max_length=30, choices=ExpenseReportStatusEnum.choices, default=ExpenseReportStatusEnum.BORRADOR, verbose_name="Estado Rendición") approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_expense_reports', verbose_name="Aprobado por") approved_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Aprobación") notes = models.TextField(blank=True, verbose_name="Observaciones / Comentarios de Cierre") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha Creación") updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") def __str__(self): return f"{self.report_code} - {self.title} ({self.user.username})" @property def total_amount(self): return sum(exp.total_amount_clp for exp in self.expenses.all()) @property def total_neto(self): return sum(exp.amount_clp for exp in self.expenses.all()) @property def expenses_count(self): return self.expenses.count() class Meta: verbose_name = "Informe de Rendición de Gastos" verbose_name_plural = "Informes de Rendición de Gastos" ordering = ['-created_at'] class Expense(TimeStampedModel): expense_code = models.CharField(max_length=50, unique=True, default=uuid.uuid4, verbose_name="Código Transacción") report = models.ForeignKey(ExpenseReport, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Informe de Rendición (Opcional)") project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expenses', verbose_name="Proyecto") category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Subcategoría") document_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="N° Folio Boleta / Factura") amount_clp = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (CLP)") tax_type = models.CharField(max_length=20, choices=TaxTypeEnum.choices, default=TaxTypeEnum.IVA_19, verbose_name="Tipo de Impuesto") tax_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Impuesto (CLP)") total_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (CLP)") amount_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (USD)") currency = models.CharField(max_length=10, choices=CurrencyEnum.choices, default=CurrencyEnum.CLP, verbose_name="Moneda Original") exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tipo de Cambio") description = models.TextField(verbose_name="Detalle de Imputación") date = models.DateField(verbose_name="Fecha del Gasto") vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Proveedor / Emisor") region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)") comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna") vehicle_ownership = models.CharField(max_length=50, blank=True, null=True, choices=VehicleOwnershipEnum.choices, verbose_name="Modalidad / Vehículo") is_reimbursement = models.BooleanField(default=False, verbose_name="¿Es Rendición / Reembolso de Gastos?") approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por") responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante / Rendidor") elevated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_elevated', verbose_name="Elevado por (Paso 2)") paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_paid', verbose_name="Pagado por (Paso 3)") rejection_reason = models.TextField(blank=True, null=True, verbose_name="Motivo de Observación/Rechazo") paid_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Pago") def clean(self): super().clean() if self.amount_clp and self.amount_clp < 0: raise ValidationError({'amount_clp': 'El monto neto no puede ser negativo.'}) amount = Decimal(str(self.amount_clp or '0')) tax = Decimal(str(self.tax_amount_clp or '0')) expected = amount + tax if self.total_amount_clp and abs(self.total_amount_clp - expected) > Decimal('1.0'): raise ValidationError({'total_amount_clp': f'Discrepancia. Total esperado: {expected}'}) def approve_expense(self, user): if self.approval_status == ApprovalStatusEnum.EN_REVISION: self.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS self.elevated_by = user self.save() def reject_expense(self, reason): self.approval_status = ApprovalStatusEnum.OBSERVADO_RECHAZADO self.rejection_reason = reason self.save() def save(self, *args, **kwargs): if not self.amount_usd or self.amount_usd == Decimal('0'): rate = Decimal(str(self.exchange_rate or '950.0')) if self.amount_clp: amount_dec = Decimal(str(self.amount_clp)) self.amount_usd = (amount_dec / rate).quantize(Decimal('0.01')) if not self.total_amount_clp or self.total_amount_clp == Decimal('0'): amount_clp = Decimal(str(self.amount_clp or '0')) tax_clp = Decimal(str(self.tax_amount_clp or '0')) self.total_amount_clp = amount_clp + tax_clp super().save(*args, **kwargs) def __str__(self): return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})" def is_vehicle_category(self): if self.category: return self.category.code == '3101' or 'combustible' in self.category.name.lower() return False class Meta: verbose_name = "Gasto" verbose_name_plural = "Gastos" ordering = ['-date', '-created_at'] class VehicleLog(models.Model): expense = models.OneToOneField(Expense, on_delete=models.CASCADE, related_name='vehicle_log', null=True, blank=True, verbose_name="Gasto Asociado") project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='vehicle_logs', verbose_name="Proyecto") license_plate = models.CharField(max_length=20, verbose_name="Patente Vehículo") driver_name = models.CharField(max_length=150, verbose_name="Nombre Conductor") driver_rut = models.CharField(max_length=20, blank=True, verbose_name="RUT Conductor") odometer_start = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Inicial (KM)") odometer_end = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Final (KM)") km_traveled = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="KM Recorridos") fuel_type = models.CharField(max_length=50, default="Diésel", verbose_name="Tipo Combustible", blank=True) fuel_liters = models.DecimalField(max_digits=8, decimal_places=2, default=0, blank=True, verbose_name="Litros Cargados") route_origin = models.CharField(max_length=150, verbose_name="Origen", default="Oficina / Base") route_destination = models.CharField(max_length=150, verbose_name="Destino / Faena") log_date = models.DateField(verbose_name="Fecha de Registro") notes = models.TextField(blank=True, verbose_name="Observaciones de Ruta") created_at = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): if self.odometer_end and self.odometer_start and self.odometer_end >= self.odometer_start: self.km_traveled = self.odometer_end - self.odometer_start super().save(*args, **kwargs) def __str__(self): return f"{self.license_plate} - {self.driver_name} ({self.km_traveled} KM)" class Meta: verbose_name = "Bitácora de Vehículo" verbose_name_plural = "Bitácoras de Vehículos" ordering = ['-log_date'] class Document(models.Model): expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado") file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto") file_type = models.CharField(max_length=50, blank=True, verbose_name="Tipo de Archivo") title = models.CharField(max_length=200, blank=True, verbose_name="Título del Documento") uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Subida") uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Subido por") def __str__(self): return f"Evidencia para {self.expense.expense_code}" @property def is_image(self): ext = self.file.name.lower().split('.')[-1] if '.' in self.file.name else '' return ext in ['jpg', 'jpeg', 'png', 'gif', 'webp'] @property def is_pdf(self): return self.file.name.lower().endswith('.pdf') class Meta: verbose_name = "Documento Evidencia" verbose_name_plural = "Documentos Evidencias" class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario") rut = models.CharField(max_length=20, blank=True, null=True, verbose_name="RUT") job_title = models.CharField(max_length=150, blank=True, null=True, verbose_name="Cargo") description = models.TextField(blank=True, verbose_name="Descripción del Responsable") specialties = models.ManyToManyField('Category', blank=True, related_name='specialists', verbose_name="Especialidades / Habilidades") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Registro") updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") def __str__(self): return f"Perfil de {self.user.username}" class Meta: verbose_name = "Perfil de Usuario" verbose_name_plural = "Perfiles de Usuario" class AuditLog(models.Model): user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario") action = models.CharField(max_length=100, verbose_name="Acción") entity_name = models.CharField(max_length=100, blank=True, null=True, verbose_name="Entidad (Tabla)") entity_id = models.CharField(max_length=100, blank=True, null=True, verbose_name="ID Entidad") field_changed = models.CharField(max_length=100, blank=True, null=True, verbose_name="Campo Modificado") old_value = models.TextField(blank=True, null=True, verbose_name="Valor Anterior") new_value = models.TextField(blank=True, null=True, verbose_name="Valor Nuevo") description = models.TextField(verbose_name="Detalle del Cambio") timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Fecha y Hora") def __str__(self): return f"{self.timestamp} - {self.user.username if self.user else 'Sistema'}: {self.action}" class Meta: ordering = ['-timestamp'] verbose_name = "Log de Auditoría (SAP CDHDR/CDPOS)" verbose_name_plural = "Logs de Auditoría" class SystemSetting(models.Model): global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i") usd_exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tasa USD/CLP por Defecto") iva_rate = models.DecimalField(max_digits=5, decimal_places=2, default=19.00, verbose_name="Tasa de IVA (% Dinámico SII)") def __str__(self): return "Configuración Global EFINTEL" class Meta: verbose_name = "Configuración del Sistema" verbose_name_plural = "Configuraciones del Sistema" class Reimbursement(Expense): class Meta: proxy = True verbose_name = "Rendición Especializada" verbose_name_plural = "Rendiciones Especializadas" def get_approval_flow(self): return "Flujo Acelerado de Reembolso"