iteracion 2
This commit is contained in:
+223
-24
@@ -1,5 +1,6 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
|
||||
# Enums
|
||||
@@ -23,6 +24,7 @@ class ApprovalStatusEnum(models.TextChoices):
|
||||
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'
|
||||
@@ -33,38 +35,95 @@ class VehicleOwnershipEnum(models.TextChoices):
|
||||
BUS = 'BUS', 'Bus'
|
||||
UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi'
|
||||
|
||||
class Project(models.Model):
|
||||
# Added core project/costcenter fields mapped from Prisma
|
||||
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
|
||||
name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro")
|
||||
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")
|
||||
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_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")
|
||||
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):
|
||||
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 Category(models.Model):
|
||||
code = models.CharField(max_length=10, unique=True, verbose_name="Código de Categoría")
|
||||
name = models.CharField(max_length=100, verbose_name="Nombre de Categoría")
|
||||
description = models.TextField(blank=True, verbose_name="Descripción")
|
||||
|
||||
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):
|
||||
@@ -74,12 +133,12 @@ class Category(models.Model):
|
||||
verbose_name = "Categoría"
|
||||
verbose_name_plural = "Categorías"
|
||||
|
||||
class Vendor(models.Model):
|
||||
|
||||
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")
|
||||
|
||||
# Banking details
|
||||
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")
|
||||
@@ -87,8 +146,6 @@ class Vendor(models.Model):
|
||||
|
||||
payment_terms = models.IntegerField(default=30, verbose_name="Plazo de Pago (Días)")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rut} - {self.name}"
|
||||
@@ -98,18 +155,61 @@ class Vendor(models.Model):
|
||||
verbose_name_plural = "Proveedores"
|
||||
ordering = ['name']
|
||||
|
||||
class Expense(models.Model):
|
||||
|
||||
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")
|
||||
|
||||
# Financial fields
|
||||
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, verbose_name="Monto Total (USD)")
|
||||
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")
|
||||
|
||||
@@ -118,33 +218,110 @@ class Expense(models.Model):
|
||||
|
||||
vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Proveedor / Emisor")
|
||||
|
||||
# Geographic & Logistic fields (Operaciones en terreno)
|
||||
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?")
|
||||
|
||||
# 3-step Approval Flow Tracking
|
||||
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, related_name='expenses_responsible', verbose_name="Responsable Solicitante")
|
||||
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")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
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")
|
||||
@@ -156,10 +333,20 @@ class Document(models.Model):
|
||||
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")
|
||||
@@ -177,6 +364,7 @@ class UserProfile(models.Model):
|
||||
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")
|
||||
@@ -197,9 +385,11 @@ class AuditLog(models.Model):
|
||||
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"
|
||||
@@ -207,3 +397,12 @@ class SystemSetting(models.Model):
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user