diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36efc86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +# Establecer variables de entorno +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Establecer directorio de trabajo +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Instalar dependencias de Python +COPY requirements.txt /app/ +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Copiar el proyecto +COPY . /app/ + +# Exponer el puerto +EXPOSE 8000 + +# Comando por defecto para iniciar Daphne (ASGI) +CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "config.asgi:application"] diff --git a/OOP_models_patch.py b/OOP_models_patch.py new file mode 100644 index 0000000..4cc60dc --- /dev/null +++ b/OOP_models_patch.py @@ -0,0 +1,30 @@ +import re + +with open('expenses/models.py', 'r', encoding='utf-8') as f: + content = f.read() + +# Clean Project +content = re.sub(r'\s+name = models\.CharField\(max_length=200, verbose_name="Nombre del Proyecto/Centro"\)', '', content) +content = re.sub(r'\s+description = models\.TextField\(blank=True, verbose_name="Descripción"\)', '', content) +content = re.sub(r'\s+created_at = models\.DateTimeField\(auto_now_add=True, verbose_name="Fecha de Creación"\)', '', content) +content = re.sub(r'\s+updated_at = models\.DateTimeField\(auto_now=True, verbose_name="Última Modificación"\)', '', content) + +# Replace Category inheritance and clean +content = content.replace("class Category(models.Model):", "class Category(CatalogModel):") +content = re.sub(r'\s+code = models\.CharField\(max_length=10, unique=True, verbose_name="Código de Categoría"\)', '', content) +content = re.sub(r'\s+name = models\.CharField\(max_length=100, verbose_name="Nombre de Categoría"\)', '', content) +content = re.sub(r'\s+description = models\.TextField\(blank=True, verbose_name="Descripción"\)', '', content) + +# Replace Vendor +content = content.replace("class Vendor(models.Model):", "class Vendor(TimeStampedModel):") +# Remove Vendor created_at/updated_at +content = re.sub(r'\s+created_at = models\.DateTimeField\(auto_now_add=True\)\n\s+updated_at = models\.DateTimeField\(auto_now=True\)', '', content) + +# Replace ProjectPhase +content = content.replace("class ProjectPhase(models.Model):", "class ProjectPhase(TimeStampedModel):") + +# Replace Expense +content = content.replace("class Expense(models.Model):", "class Expense(TimeStampedModel):") + +with open('expenses/models.py', 'w', encoding='utf-8') as f: + f.write(content) diff --git a/config/__init__.py b/config/__init__.py index e69de29..fb989c4 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..2067c01 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,20 @@ +import os +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('config') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/config/settings.py b/config/settings.py index 85c717d..2ea9eaa 100644 --- a/config/settings.py +++ b/config/settings.py @@ -11,21 +11,28 @@ https://docs.djangoproject.com/en/6.1/ref/settings/ """ from pathlib import Path +import environ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Inicializar environ +env = environ.Env( + DEBUG=(bool, False) +) +# Leer .env si existe +environ.Env.read_env(BASE_DIR / '.env') # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-r9n6j1_h%@uqoz0bjz^mq=t72o0t*j@84jpl%_i4^qu%&+**u7' +SECRET_KEY = env('SECRET_KEY', default='django-insecure-fallback-key') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = env('DEBUG') -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) # Application definition @@ -41,6 +48,9 @@ INSTALLED_APPS = [ 'django.contrib.humanize', 'channels', 'expenses', + 'payroll', + 'procurement', + 'talent', ] MIDDLEWARE = [ @@ -74,10 +84,12 @@ WSGI_APPLICATION = 'config.wsgi.application' ASGI_APPLICATION = 'config.asgi.application' # Configuracion de WebSockets (Channels) -# Para produccion (VPS), cambiar a 'channels_redis.core.RedisChannelLayer' CHANNEL_LAYERS = { "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer" + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [env('REDIS_URL', default='redis://localhost:6379/0')], + }, } } @@ -86,10 +98,7 @@ CHANNEL_LAYERS = { # https://docs.djangoproject.com/en/6.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } + 'default': env.db('DATABASE_URL', default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}") } @@ -117,7 +126,7 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = 'es-cl' -TIME_ZONE = 'UTC' +TIME_ZONE = 'America/Santiago' USE_I18N = True @@ -149,3 +158,11 @@ MAILERS = { 'BACKEND': 'django.core.mail.backends.console.EmailBackend', }, } + +# Celery Configuration +CELERY_BROKER_URL = env('REDIS_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = env('REDIS_URL', default='redis://localhost:6379/0') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE diff --git a/config/urls.py b/config/urls.py index b0782c1..1fcf140 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,27 +1,21 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/6.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from ninja import NinjaAPI +from expenses.api import router as expenses_router + +api = NinjaAPI(title="EFINTEL ERP API", version="1.0.0") +api.add_router("/", expenses_router) + urlpatterns = [ path('admin/', admin.site.urls), + path('api/v1/', api.urls), path('', include('expenses.urls')), + path('remuneraciones/', include('payroll.urls')), + path('compras/', include('procurement.urls')), + path('talento/', include('talent.urls')), ] if settings.DEBUG: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da98db7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: efintel_db + POSTGRES_USER: efintel_user + POSTGRES_PASSWORD: efintel_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + + web: + build: . + command: daphne -b 0.0.0.0 -p 8000 config.asgi:application + volumes: + - .:/app + ports: + - "8000:8000" + env_file: + - .env + environment: + DATABASE_URL: postgres://efintel_user:efintel_password@db:5432/efintel_db + REDIS_URL: redis://redis:6379/0 + depends_on: + - db + - redis + + celery: + build: . + command: celery -A config worker -l info + volumes: + - .:/app + env_file: + - .env + environment: + DATABASE_URL: postgres://efintel_user:efintel_password@db:5432/efintel_db + REDIS_URL: redis://redis:6379/0 + depends_on: + - db + - redis + +volumes: + postgres_data: + redis_data: diff --git a/expenses/api.py b/expenses/api.py new file mode 100644 index 0000000..68119f8 --- /dev/null +++ b/expenses/api.py @@ -0,0 +1,55 @@ +from ninja import Router +from pydantic import BaseModel +from typing import List, Optional +from decimal import Decimal +from .services.sii import SIIService +from .models import Project + +router = Router() + +class RUTValidationResponse(BaseModel): + is_valid: bool + formatted_rut: str + error_message: Optional[str] = None + +@router.get("/sii/validate-rut", response=RUTValidationResponse) +def validate_rut(request, rut: str): + """ + Valida un RUT chileno usando la lógica del SIIService. + """ + is_valid, result = SIIService.validate_rut(rut) + if is_valid: + return RUTValidationResponse(is_valid=True, formatted_rut=result) + else: + return RUTValidationResponse(is_valid=False, formatted_rut="", error_message=result) + +class ProjectOut(BaseModel): + id: int + code: str + name: str + +@router.get("/projects/", response=List[ProjectOut]) +def get_projects(request): + """ + Obtiene la lista de proyectos reales (excluye Portafolios y Centros de Costo). + """ + # Proyectos reales: parent__parent__isnull=False + qs = Project.objects.filter(parent__parent__isnull=False, status='Activo').order_by('name') + return [ProjectOut(id=p.id, code=p.code, name=p.name) for p in qs] + +class TaxCalculationResponse(BaseModel): + net_amount: float + tax_amount: float + total_amount: float + +@router.get("/sii/calculate-tax", response=TaxCalculationResponse) +def calculate_tax(request, amount: float, tax_type: str): + """ + Calcula el impuesto y el total basado en el tipo de documento. + """ + res = SIIService.calculate_taxes(amount, tax_type) + return TaxCalculationResponse( + net_amount=float(res['net_amount']), + tax_amount=float(res['tax_amount']), + total_amount=float(res['total_amount']) + ) diff --git a/expenses/forms.py b/expenses/forms.py index 9387443..5a7773e 100644 --- a/expenses/forms.py +++ b/expenses/forms.py @@ -1,9 +1,12 @@ from django import forms from django.contrib.auth.models import User, Group -from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor, VehicleOwnershipEnum +from .models import ( + Expense, Project, Category, SystemSetting, UserProfile, Document, + CurrencyEnum, ExpenseTypeEnum, Vendor, VehicleOwnershipEnum, + ExpenseReport, VehicleLog, ExpenseReportStatusEnum +) from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES - - +from .services.sii import SIIService from decimal import Decimal import os @@ -29,10 +32,23 @@ class ExpenseForm(forms.ModelForm): label='Fecha del Gasto' ) + is_reimbursement = forms.BooleanField( + label='¿Es Rendición / Reembolso de Gastos?', + required=False, + initial=False, + widget=forms.CheckboxInput(attrs={'id': 'id_is_reimbursement', 'x-model': 'isReimbursement'}) + ) + amount_clp = forms.CharField( label='Monto Neto (CLP)', required=True, - widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000'}) + widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000', 'id': 'id_amount_clp', 'x-model': 'amountClp', '@input': 'formatAmount(); calculateTax()'}) + ) + + document_number = forms.CharField( + label='N° Folio Boleta / Factura (DTE)', + required=False, + widget=forms.TextInput(attrs={'placeholder': 'Ej: 10482', 'id': 'id_document_number'}) ) receipt = MultipleFileField( @@ -61,19 +77,19 @@ class ExpenseForm(forms.ModelForm): tax_amount_clp = forms.CharField( label='Monto Impuesto (CLP)', required=False, - widget=forms.TextInput(attrs={'readonly': 'readonly'}) + widget=forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_tax_amount_clp', ':value': 'taxAmountClp'}) ) total_amount_clp = forms.CharField( label='Monto Total (CLP)', required=False, - widget=forms.TextInput(attrs={'readonly': 'readonly'}) + widget=forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_total_amount_clp', ':value': 'totalAmountClp'}) ) new_vendor_rut = forms.CharField( label="RUT Nuevo Proveedor", required=False, - widget=forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut'}) + widget=forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut', 'x-model': 'newVendorRut', '@blur': 'validateRut()', ':class': "rutError ? 'border-red-500' : ''"}) ) new_vendor_name = forms.CharField( @@ -82,16 +98,49 @@ class ExpenseForm(forms.ModelForm): widget=forms.TextInput(attrs={'placeholder': 'Ej: IMPORTADORA SPA', 'id': 'id_new_vendor_name'}) ) + # Conditional Vehicle Log Fields + v_license_plate = forms.CharField(label="Patente Vehículo", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: ABCD-12'})) + v_driver_name = forms.CharField(label="Nombre Conductor / Responsable", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: Juan Pérez'})) + v_odometer_start = forms.DecimalField(label="Odómetro Inicial (KM)", required=False, initial=Decimal('0'), widget=forms.NumberInput(attrs={'step': '1'})) + v_odometer_end = forms.DecimalField(label="Odómetro Final (KM)", required=False, initial=Decimal('0'), widget=forms.NumberInput(attrs={'step': '1'})) + v_fuel_type = forms.CharField(label="Tipo Combustible", required=False, initial="Diésel", widget=forms.TextInput(attrs={'placeholder': 'Ej: Diésel / 95 Octanos'})) + v_route_origin = forms.CharField(label="Origen", required=False, initial="Base EFINTEL", widget=forms.TextInput()) + v_route_destination = forms.CharField(label="Destino / Faena", required=False, widget=forms.TextInput(attrs={'placeholder': 'Ej: Faena Minera Spence'})) + class Meta: model = Expense fields = [ - 'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date', + 'project', 'category', 'is_reimbursement', 'report', 'document_number', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date', 'description', 'vendor', 'region', 'comuna', 'vehicle_ownership' ] widgets = { - 'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}), + 'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno', 'required': 'required'}), + 'is_reimbursement': forms.CheckboxInput(attrs={'id': 'id_is_reimbursement', 'x-model': 'isReimbursement'}), + 'amount_clp': forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000', 'id': 'id_amount_clp', 'x-model': 'amountClp', '@input': 'formatAmount(); calculateTax()', 'required': 'required', 'pattern': '^[0-9.]+$', 'title': 'Ingrese solo números y puntos'}), + 'tax_type': forms.Select(attrs={'x-model': 'taxType', '@change': 'calculateTax()', 'required': 'required'}), + 'tax_amount_clp': forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_tax_amount_clp', ':value': 'taxAmountClp'}), + 'total_amount_clp': forms.TextInput(attrs={'readonly': 'readonly', 'id': 'id_total_amount_clp', ':value': 'totalAmountClp'}), + 'new_vendor_rut': forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut', 'x-model': 'newVendorRut', '@blur': 'validateRut()', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}), + 'region': forms.Select(attrs={'x-model': 'selectedRegion', '@change': 'updateComunas()'}), + 'comuna': forms.Select(attrs={'x-model': 'selectedComuna'}), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False) + if self.instance and self.instance.pk: + try: + vlog = self.instance.vehicle_log + self.fields['v_license_plate'].initial = vlog.license_plate + self.fields['v_driver_name'].initial = vlog.driver_name + self.fields['v_odometer_start'].initial = vlog.odometer_start + self.fields['v_odometer_end'].initial = vlog.odometer_end + self.fields['v_fuel_type'].initial = vlog.fuel_type + self.fields['v_route_origin'].initial = vlog.route_origin + self.fields['v_route_destination'].initial = vlog.route_destination + except Exception: + pass + def clean_amount_clp(self): data = self.cleaned_data.get('amount_clp', '') if not data: @@ -132,11 +181,60 @@ class ExpenseForm(forms.ModelForm): def clean(self): cleaned_data = super().clean() + is_reimb = cleaned_data.get('is_reimbursement', False) + resp_person = cleaned_data.get('responsible_person') + project = cleaned_data.get('project') + + # Requerimiento 7: Lógica de Rendición vs Centro I+D+i + if is_reimb: + if not resp_person: + raise forms.ValidationError("Si el gasto es una rendición, debe seleccionar el responsable de la rendición.") + else: + # Si NO es rendición, pasa al centro de costos I+D+i + if not project: + idi_proj = Project.objects.filter(models.Q(code__icontains='IDI') | models.Q(name__icontains='I+D+i') | models.Q(type=ExpenseTypeEnum.COSTO_DIRECTO)).first() + if idi_proj: + cleaned_data['project'] = idi_proj + new_name = cleaned_data.get('new_vendor_name') new_rut = cleaned_data.get('new_vendor_rut') + if new_rut: + is_valid, res = SIIService.validate_rut(new_rut) + if not is_valid: + raise forms.ValidationError(f"RUT de proveedor inválido: {res}") + cleaned_data['new_vendor_rut'] = res + if (new_name and not new_rut) or (new_rut and not new_name): raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.") + + # Recalculate taxes via SIIService + amount = cleaned_data.get('amount_clp') + tax_type = cleaned_data.get('tax_type') + if amount and tax_type: + tax_res = SIIService.calculate_taxes(amount, tax_type) + cleaned_data['tax_amount_clp'] = tax_res['tax_amount'] + cleaned_data['total_amount_clp'] = tax_res['total_amount'] + + # Duplicate check alert + vendor = cleaned_data.get('vendor') + doc_num = cleaned_data.get('document_number') + date_val = cleaned_data.get('date') + if amount and (vendor or doc_num): + duplicates = SIIService.check_duplicate_expense( + expense_model=Expense, + vendor_id=vendor.id if vendor else None, + amount_clp=amount, + date_val=date_val, + document_number=doc_num, + exclude_id=self.instance.pk if self.instance else None + ) + if duplicates: + dup_str = ", ".join([f"{d.expense_code} ({d.date}, ${d.amount_clp:,.0f})" for d in duplicates]) + # We can allow with warning or raise ValidationError if identical document number + if doc_num and any(d.document_number == doc_num for d in duplicates): + raise forms.ValidationError(f"¡Alerta Anti-Fraude! Ya existe un gasto registrado con el mismo Folio/Documento N° {doc_num}: {dup_str}") + return cleaned_data def save(self, commit=True): @@ -144,7 +242,6 @@ class ExpenseForm(forms.ModelForm): new_name = self.cleaned_data.get('new_vendor_name') new_rut = self.cleaned_data.get('new_vendor_rut') if new_name and new_rut: - from .models import Vendor vendor, _ = Vendor.objects.get_or_create( rut=new_rut.strip(), defaults={'name': new_name.strip()} @@ -152,15 +249,114 @@ class ExpenseForm(forms.ModelForm): instance.vendor = vendor if commit: instance.save() + + # Requerimiento 14: Save VehicleLog ONLY if combustible category + if instance.is_vehicle_category(): + plate = self.cleaned_data.get('v_license_plate') + driver = self.cleaned_data.get('v_driver_name') + if plate or driver: + vlog, _ = VehicleLog.objects.get_or_create( + expense=instance, + defaults={ + 'project': instance.project, + 'license_plate': plate or 'S/P', + 'driver_name': driver or instance.user.username, + 'odometer_start': self.cleaned_data.get('v_odometer_start') or Decimal('0'), + 'odometer_end': self.cleaned_data.get('v_odometer_end') or Decimal('0'), + 'fuel_type': self.cleaned_data.get('v_fuel_type') or 'Diésel', + 'route_origin': self.cleaned_data.get('v_route_origin') or 'Base', + 'route_destination': self.cleaned_data.get('v_route_destination') or 'Faena', + 'log_date': instance.date, + } + ) + if not _: + vlog.license_plate = plate or vlog.license_plate + vlog.driver_name = driver or vlog.driver_name + vlog.odometer_start = self.cleaned_data.get('v_odometer_start') or vlog.odometer_start + vlog.odometer_end = self.cleaned_data.get('v_odometer_end') or vlog.odometer_end + vlog.fuel_type = self.cleaned_data.get('v_fuel_type') or vlog.fuel_type + vlog.route_origin = self.cleaned_data.get('v_route_origin') or vlog.route_origin + vlog.route_destination = self.cleaned_data.get('v_route_destination') or vlog.route_destination + vlog.save() return instance +class ExpenseReportForm(forms.ModelForm): + start_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Inicio") + end_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Término") + + class Meta: + model = ExpenseReport + fields = ['title', 'project', 'start_date', 'end_date', 'purpose', 'notes'] + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Viaje Faena Antofagasta - Pruebas Piloto'}), + 'project': forms.Select(attrs={'class': 'form-control'}), + 'purpose': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Objetivos del viaje, comisiones en terreno o faena...'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Comentarios adicionales...'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False) + + +class VehicleLogForm(forms.ModelForm): + log_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), label="Fecha de Registro") + + class Meta: + model = VehicleLog + fields = ['expense', 'project', 'license_plate', 'driver_name', 'driver_rut', 'odometer_start', 'odometer_end', 'fuel_type', 'fuel_liters', 'route_origin', 'route_destination', 'log_date', 'notes'] + widgets = { + 'expense': forms.Select(attrs={'class': 'form-control'}), + 'project': forms.Select(attrs={'class': 'form-control'}), + 'license_plate': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ABCD-12'}), + 'driver_name': forms.TextInput(attrs={'class': 'form-control'}), + 'driver_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '12.345.678-9'}), + 'odometer_start': forms.NumberInput(attrs={'class': 'form-control', 'step': '1'}), + 'odometer_end': forms.NumberInput(attrs={'class': 'form-control', 'step': '1'}), + 'fuel_type': forms.TextInput(attrs={'class': 'form-control'}), + 'fuel_liters': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}), + 'route_origin': forms.TextInput(attrs={'class': 'form-control'}), + 'route_destination': forms.TextInput(attrs={'class': 'form-control'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False) + + class ProjectForm(forms.ModelForm): class Meta: model = Project - fields = ['code', 'name', 'parent', 'category', 'status', 'type', 'description', 'manager', 'assigned_budget_clp'] + fields = [ + 'code', 'name', 'parent', 'category', 'status', 'type', + 'manager', 'assigned_budget_clp', 'assigned_budget_usd', + 'payment_method', 'total_income_clp', 'description' + ] widgets = { - 'description': forms.Textarea(attrs={'rows': 3}), + 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), + 'code': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}), + 'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}), + 'assigned_budget_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}), + 'assigned_budget_usd': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'step': '0.01'}), + 'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}), + } + + +class ProjectPhaseForm(forms.ModelForm): + due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada") + + class Meta: + from .models import ProjectPhase + model = ProjectPhase + fields = ['phase_number', 'name', 'planned_amount_clp', 'income_amount_clp', 'expense_amount_clp', 'due_date', 'is_completed', 'description'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Hito 1 - Entrega Prototipo', 'required': 'required'}), + 'description': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), + 'planned_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}), + 'income_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}), + 'expense_amount_clp': forms.NumberInput(attrs={'class': 'form-control', 'min': '0'}), } @@ -177,12 +373,26 @@ class VendorForm(forms.ModelForm): class Meta: model = Vendor fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms'] + widgets = { + 'rut': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}), + 'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required'}), + 'email': forms.EmailInput(attrs={'class': 'form-control'}), + } + + def clean_rut(self): + rut = self.cleaned_data.get('rut') + if rut: + is_valid, formatted_or_err = SIIService.validate_rut(rut) + if not is_valid: + raise forms.ValidationError(formatted_or_err) + return formatted_or_err + return rut class SystemSettingForm(forms.ModelForm): class Meta: model = SystemSetting - fields = ['global_budget', 'usd_exchange_rate'] + fields = ['iva_rate', 'usd_exchange_rate'] class ResponsableForm(forms.Form): @@ -208,11 +418,9 @@ class ResponsableForm(forms.Form): if self.instance: self.fields['username'].initial = self.instance.username self.fields['email'].initial = self.instance.email - # Set role groups = self.instance.groups.all() if groups.exists(): self.fields['role'].initial = groups.first() - # Set profile info try: self.fields['description'].initial = self.instance.profile.description self.fields['rut'].initial = self.instance.profile.rut @@ -220,7 +428,6 @@ class ResponsableForm(forms.Form): self.fields['specialties'].initial = self.instance.profile.specialties.all() except Exception: pass - # Password is optional for edit self.fields['password'].required = False self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla." @@ -259,7 +466,6 @@ class ResponsableForm(forms.Form): user.is_superuser = False user.save() - user.groups.clear() user.groups.add(role) @@ -269,7 +475,6 @@ class ResponsableForm(forms.Form): profile.job_title = job_title profile.save() - # Save specialties specialties = self.cleaned_data.get('specialties') if specialties is not None: profile.specialties.set(specialties) diff --git a/expenses/migrations/0008_expensereport_document_number_vehiclelog.py b/expenses/migrations/0008_expensereport_document_number_vehiclelog.py new file mode 100644 index 0000000..4cd5fe9 --- /dev/null +++ b/expenses/migrations/0008_expensereport_document_number_vehiclelog.py @@ -0,0 +1,76 @@ +# Generated for EFINTEL ERP Enhancements + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0007_alter_expense_vehicle_ownership'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExpenseReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report_code', models.CharField(default='EXP-REP-001', max_length=50, unique=True, verbose_name='Código Rendición')), + ('title', models.CharField(max_length=250, verbose_name='Título del Informe / Viaje')), + ('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(choices=[('BORRADOR', 'Borrador'), ('ENVIADO', 'Enviado para Aprobación'), ('APROBADO', 'Aprobado'), ('REEMBOLSADO', 'Reembolsado / Pagado'), ('OBSERVADO', 'Observado / Rechazado')], default='BORRADOR', max_length=30, verbose_name='Estado Rendición')), + ('approved_at', models.DateTimeField(blank=True, null=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')), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_expense_reports', to=settings.AUTH_USER_MODEL, verbose_name='Aprobado por')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expense_reports', to='expenses.project', verbose_name='Proyecto Imputado')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expense_reports', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Rendidor')), + ], + options={ + 'verbose_name': 'Informe de Rendición de Gastos', + 'verbose_name_plural': 'Informes de Rendición de Gastos', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='expense', + name='document_number', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='N° Folio Boleta / Factura'), + ), + migrations.AddField( + model_name='expense', + name='report', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.expensereport', verbose_name='Informe de Rendición (Opcional)'), + ), + migrations.CreateModel( + name='VehicleLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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(blank=True, max_length=20, verbose_name='RUT Conductor')), + ('odometer_start', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='Odómetro Inicial (KM)')), + ('odometer_end', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='Odómetro Final (KM)')), + ('km_traveled', models.DecimalField(decimal_places=1, default=0, max_digits=10, verbose_name='KM Recorridos')), + ('fuel_type', models.CharField(blank=True, default='Diésel', max_length=50, verbose_name='Tipo Combustible')), + ('fuel_liters', models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=8, verbose_name='Litros Cargados')), + ('route_origin', models.CharField(default='Oficina / Base', max_length=150, verbose_name='Origen')), + ('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)), + ('expense', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vehicle_log', to='expenses.expense', verbose_name='Gasto Asociado')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vehicle_logs', to='expenses.project', verbose_name='Proyecto')), + ], + options={ + 'verbose_name': 'Bitácora de Vehículo', + 'verbose_name_plural': 'Bitácoras de Vehículos', + 'ordering': ['-log_date'], + }, + ), + ] diff --git a/expenses/migrations/0009_expense_is_reimbursement_project_payment_method_and_more.py b/expenses/migrations/0009_expense_is_reimbursement_project_payment_method_and_more.py new file mode 100644 index 0000000..833ba4d --- /dev/null +++ b/expenses/migrations/0009_expense_is_reimbursement_project_payment_method_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 6.1 on 2026-09-07 02:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0008_expensereport_document_number_vehiclelog'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='is_reimbursement', + field=models.BooleanField(default=False, verbose_name='¿Es Rendición / Reembolso de Gastos?'), + ), + migrations.AddField( + model_name='project', + name='payment_method', + field=models.CharField(default='Contrato por Fases / Hitos', max_length=150, verbose_name='Forma de Pago'), + ), + migrations.AddField( + model_name='project', + name='total_income_clp', + field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Ingresos Totales (CLP)'), + ), + migrations.AddField( + model_name='systemsetting', + name='iva_rate', + field=models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='Tasa de IVA (% Dinámico SII)'), + ), + migrations.AlterField( + model_name='expense', + name='responsible_person', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_responsible', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Solicitante / Rendidor'), + ), + migrations.AlterField( + model_name='project', + name='manager', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_projects', to=settings.AUTH_USER_MODEL, verbose_name='Encargado / Director'), + ), + migrations.AlterField( + model_name='project', + name='status', + field=models.CharField(choices=[('BORRADOR', 'Borrador / En Diseño'), ('ACTIVO', 'Activo'), ('PAUSADA', 'En Pausa'), ('TERMINADO', 'Terminado')], default='ACTIVO', max_length=50, verbose_name='Estado de Proyecto'), + ), + migrations.CreateModel( + name='ProjectPhase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Planificado / Pactado (CLP)')), + ('income_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Ingresado / Cobrado (CLP)')), + ('expense_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Egresado / Gastado (CLP)')), + ('due_date', models.DateField(blank=True, null=True, verbose_name='Fecha Pactada')), + ('is_completed', models.BooleanField(default=False, verbose_name='Fase Completada')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phases', to='expenses.project', verbose_name='Proyecto')), + ], + options={ + 'verbose_name': 'Fase / Hito de Pago de Proyecto', + 'verbose_name_plural': 'Fases / Hitos de Pago de Proyecto', + 'ordering': ['phase_number'], + }, + ), + ] diff --git a/expenses/migrations/0010_alter_expense_amount_usd.py b/expenses/migrations/0010_alter_expense_amount_usd.py new file mode 100644 index 0000000..ddab224 --- /dev/null +++ b/expenses/migrations/0010_alter_expense_amount_usd.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-09-07 02:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0009_expense_is_reimbursement_project_payment_method_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='amount_usd', + field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Total (USD)'), + ), + ] diff --git a/expenses/migrations/0011_reimbursement_category_created_at_and_more.py b/expenses/migrations/0011_reimbursement_category_created_at_and_more.py new file mode 100644 index 0000000..74cb928 --- /dev/null +++ b/expenses/migrations/0011_reimbursement_category_created_at_and_more.py @@ -0,0 +1,88 @@ +# Generated by Django 6.1 on 2026-09-07 15:20 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0010_alter_expense_amount_usd'), + ] + + operations = [ + migrations.CreateModel( + name='Reimbursement', + fields=[ + ], + options={ + 'verbose_name': 'Rendición Especializada', + 'verbose_name_plural': 'Rendiciones Especializadas', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('expenses.expense',), + ), + migrations.AddField( + model_name='category', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Fecha de Creación'), + preserve_default=False, + ), + migrations.AddField( + model_name='category', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'), + ), + migrations.AlterField( + model_name='category', + name='code', + field=models.CharField(max_length=50, unique=True, verbose_name='Código'), + ), + migrations.AlterField( + model_name='category', + name='name', + field=models.CharField(max_length=200, verbose_name='Nombre'), + ), + migrations.AlterField( + model_name='expense', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'), + ), + migrations.AlterField( + model_name='expense', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'), + ), + migrations.AlterField( + model_name='project', + name='code', + field=models.CharField(max_length=50, unique=True, verbose_name='Código'), + ), + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=200, verbose_name='Nombre'), + ), + migrations.AlterField( + model_name='projectphase', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'), + ), + migrations.AlterField( + model_name='projectphase', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'), + ), + migrations.AlterField( + model_name='vendor', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación'), + ), + migrations.AlterField( + model_name='vendor', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Última Modificación'), + ), + ] diff --git a/expenses/models.py b/expenses/models.py index 46c571c..6e0cc8f 100644 --- a/expenses/models.py +++ b/expenses/models.py @@ -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" diff --git a/expenses/services/__init__.py b/expenses/services/__init__.py new file mode 100644 index 0000000..79bc020 --- /dev/null +++ b/expenses/services/__init__.py @@ -0,0 +1,3 @@ +from .sii import SIIService + +__all__ = ['SIIService'] diff --git a/expenses/services/sii.py b/expenses/services/sii.py new file mode 100644 index 0000000..a43c5e7 --- /dev/null +++ b/expenses/services/sii.py @@ -0,0 +1,193 @@ +""" +SII and Chilean Tax / Document Validation Service for EFINTEL ERP. +Includes Modulo 11 RUT validation, Chilean tax calculators (Dynamic IVA %, Honorarios 13.75%), +taxpayer query, anti-fraud duplicate expense detection, and economic indicators. +""" + +from decimal import Decimal, ROUND_HALF_UP +import re +from typing import Tuple, List, Optional, Dict, Any +from django.db.models import Q + +class SIIService: + DEFAULT_IVA_RATE = Decimal('0.19') + HONORARIOS_RETENTION_RATE = Decimal('0.1375') # Current Chilean 13.75% retention + + @classmethod + def get_current_iva_rate(cls) -> Decimal: + """Fetch dynamic IVA rate from SystemSetting or fallback to default 19%.""" + try: + from expenses.models import SystemSetting + setting = SystemSetting.objects.first() + if setting and setting.iva_rate: + return setting.iva_rate / Decimal('100.0') + except Exception: + pass + return cls.DEFAULT_IVA_RATE + + @staticmethod + def clean_rut(rut: str) -> str: + """Removes dots, hyphens, and whitespace, converting verification digit to uppercase.""" + if not rut: + return "" + return re.sub(r'[^0-9kK]', '', str(rut)).upper() + + @classmethod + def validate_rut(cls, rut: str) -> Tuple[bool, str]: + """ + Validates Chilean RUT using strict Modulo 11 algorithm. + Returns (is_valid: bool, formatted_rut_or_error: str). + """ + cleaned = cls.clean_rut(rut) + if len(cleaned) < 2: + return False, "RUT demasiado corto." + + body = cleaned[:-1] + dv = cleaned[-1] + + if not body.isdigit(): + return False, "El cuerpo del RUT debe contener sólo dígitos." + + # Modulo 11 calculation + reversed_digits = [int(d) for d in reversed(body)] + factors = [2, 3, 4, 5, 6, 7] + total = 0 + for i, digit in enumerate(reversed_digits): + factor = factors[i % len(factors)] + total += digit * factor + + remainder = total % 11 + expected_dv_num = 11 - remainder + if expected_dv_num == 11: + expected_dv = '0' + elif expected_dv_num == 10: + expected_dv = 'K' + else: + expected_dv = str(expected_dv_num) + + if dv == expected_dv: + formatted_body = f"{int(body):,}".replace(',', '.') + formatted_rut = f"{formatted_body}-{dv}" + return True, formatted_rut + else: + return False, f"Dígito verificador inválido (se esperaba {expected_dv})." + + @classmethod + def get_taxpayer_info(cls, rut: str) -> Dict[str, Any]: + """ + Queries taxpayer information (Razón Social, Giro, Status) from database or SII lookup service. + """ + is_valid, formatted_rut = cls.validate_rut(rut) + if not is_valid: + return {'valid': False, 'error': formatted_rut} + + from expenses.models import Vendor + vendor = Vendor.objects.filter(Q(rut=formatted_rut) | Q(rut=cls.clean_rut(rut))).first() + if vendor: + return { + 'valid': True, + 'rut': vendor.rut, + 'name': vendor.name, + 'business_line': vendor.business_line or 'Giro No Especificado', + 'bank': vendor.bank or '', + 'account_type': vendor.account_type or '', + 'account_number': vendor.account_number or '', + 'source': 'ERP_DB' + } + + # Simulated SII API Response for validated RUTs + return { + 'valid': True, + 'rut': formatted_rut, + 'name': f"CONTRIBUYENTE SII {formatted_rut}", + 'business_line': "Servicios de Ingeniería, Tecnología y Consultoría I+D+i", + 'bank': "", + 'account_type': "", + 'account_number': "", + 'source': 'SII_API' + } + + @classmethod + def calculate_taxes(cls, net_amount_clp: Decimal, tax_type: str, custom_iva_percent: Optional[Decimal] = None) -> dict: + """ + Calculates tax amounts and gross/net totals based on Chilean tax type and dynamic IVA %: + - IVA_19 / AFECTO: Total = Neto + (Neto * IVA%) + - HONORARIOS_13_75: Retention 13.75% + - EXENTO: Total = Neto, Tax = 0 + """ + if not net_amount_clp or net_amount_clp < 0: + net_amount_clp = Decimal('0') + + if custom_iva_percent is not None: + iva_rate = custom_iva_percent / Decimal('100.0') + iva_percent_val = custom_iva_percent + else: + iva_rate = cls.get_current_iva_rate() + iva_percent_val = (iva_rate * Decimal('100.0')).quantize(Decimal('0.1')) + + if tax_type in ('IVA_19', 'IVA', 'AFECTO'): + tax_amount = (net_amount_clp * iva_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + total_amount = net_amount_clp + tax_amount + return { + 'tax_type': 'IVA_19', + 'net_amount': net_amount_clp, + 'tax_rate_percent': iva_percent_val, + 'tax_amount': tax_amount, + 'total_amount': total_amount, + 'description': f'Afecto a IVA ({iva_percent_val:g}%)' + } + elif tax_type in ('HONORARIOS_13_75', 'HONORARIOS'): + tax_amount = (net_amount_clp * cls.HONORARIOS_RETENTION_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + total_amount = net_amount_clp + tax_amount + return { + 'tax_type': 'HONORARIOS_13_75', + 'net_amount': net_amount_clp, + 'tax_rate_percent': Decimal('13.75'), + 'tax_amount': tax_amount, + 'total_amount': total_amount, + 'description': 'Retención Boleta de Honorarios 13.75%' + } + else: + return { + 'tax_type': 'EXENTO', + 'net_amount': net_amount_clp, + 'tax_rate_percent': Decimal('0.0'), + 'tax_amount': Decimal('0'), + 'total_amount': net_amount_clp, + 'description': 'Exento / No Afecto a Impuestos' + } + + @staticmethod + def check_duplicate_expense( + expense_model, + vendor_id: Optional[int], + amount_clp: Decimal, + date_val, + document_number: Optional[str] = None, + exclude_id: Optional[int] = None + ) -> List: + """ + Detects potential duplicate expenses to prevent fraud or double-reimbursement. + """ + if not amount_clp: + return [] + + query = Q() + if document_number and str(document_number).strip(): + doc_clean = str(document_number).strip() + if vendor_id: + query |= Q(vendor_id=vendor_id, document_number__iexact=doc_clean) + else: + query |= Q(document_number__iexact=doc_clean) + + if vendor_id and date_val: + query |= Q(vendor_id=vendor_id, amount_clp=amount_clp, date=date_val) + + if not query: + return [] + + qs = expense_model.objects.filter(query) + if exclude_id: + qs = qs.exclude(id=exclude_id) + + return list(qs.select_related('vendor', 'project', 'user')[:5]) diff --git a/expenses/tests.py b/expenses/tests.py index 7ce503c..ae735de 100644 --- a/expenses/tests.py +++ b/expenses/tests.py @@ -1,3 +1,176 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.contrib.auth.models import User, Group +from decimal import Decimal +from datetime import date +from expenses.models import ( + Expense, Project, Category, SystemSetting, Vendor, ProjectPhase, + ProjectStatusEnum, TaxTypeEnum, ExpenseTypeEnum, VehicleLog +) +from expenses.services.sii import SIIService +from talent.models import SpecialistCategoryEnum, SpecialistCandidate +from procurement.models import ItemCatalog, Category as ProcurementCategory -# Create your tests here. +class ERPRequirementsTestCase(TestCase): + def setUp(self): + self.client = Client() + self.admin = User.objects.create_superuser('admin_user', 'admin@efintel.cl', 'password123') + self.pm_group, _ = Group.objects.get_or_create(name='Director de Proyecto') + self.pm_user = User.objects.create_user('pm_user', 'pm@efintel.cl', 'password123') + self.pm_user.groups.add(self.pm_group) + + self.setting = SystemSetting.objects.create(id=1, usd_exchange_rate=950.0, iva_rate=19.00) + + self.category_combustible = Category.objects.create(code='3101', name='Combustibles / Bencina') + self.category_office = Category.objects.create(code='2301', name='Arriendo de Oficinas y Espacios de Trabajo') + self.category_courses = Category.objects.create(code='4201', name='Cursos, Capacitación y Certificaciones Técnicas para Especialistas') + self.category_ai = Category.objects.create(code='7000', name='Software, Datos, Inteligencia Artificial y Cloud') + + self.project_idi = Project.objects.create( + code='CC-IDI-01', + name='Centro I+D+i Central', + assigned_budget_clp=10000000, + payment_method='Mensual', + total_income_clp=15000000, + type=ExpenseTypeEnum.COSTO_DIRECTO, + status=ProjectStatusEnum.ACTIVO, + manager=self.pm_user + ) + + def test_req_1_and_5_dynamic_iva_and_settings(self): + """Test Requerimiento 1 (No global_budget in dashboard) & Requerimiento 5 (Dynamic IVA).""" + self.client.login(username='admin_user', password='password123') + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + self.assertNotIn('PRESUPUESTO GENERAL I+D+i', response.content.decode()) + + # Test Dynamic IVA (Change to 21%) + self.setting.iva_rate = Decimal('21.00') + self.setting.save() + calc = SIIService.calculate_taxes(Decimal('100000'), 'IVA_19') + self.assertEqual(calc['tax_amount'], Decimal('21000')) + self.assertEqual(calc['total_amount'], Decimal('121000')) + + def test_req_2_exact_project_count(self): + """Test Requerimiento 2: Exact project count by status.""" + Project.objects.create(code='PROY-P1', name='Pausado', status=ProjectStatusEnum.PAUSADA) + Project.objects.create(code='PROY-T1', name='Terminado', status=ProjectStatusEnum.TERMINADO) + Project.objects.create(code='PROY-D1', name='Borrador', status=ProjectStatusEnum.BORRADOR) + + self.client.login(username='admin_user', password='password123') + res = self.client.get('/proyectos/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context['project_count_active'], 1) + self.assertEqual(res.context['project_count_paused'], 1) + self.assertEqual(res.context['project_count_completed'], 1) + self.assertEqual(res.context['project_count_draft'], 1) + self.assertEqual(res.context['project_count_total'], 4) + + def test_req_4_sii_integration(self): + """Test Requerimiento 4: SII API service and taxpayer info query.""" + is_valid, formatted = SIIService.validate_rut('76543210-3') + self.assertTrue(is_valid) + self.assertEqual(formatted, '76.543.210-3') + + info = SIIService.get_taxpayer_info('76.543.210-3') + self.assertTrue(info['valid']) + self.assertEqual(info['rut'], '76.543.210-3') + + def test_req_6_vendor_draft_mitigation(self): + """Test Requerimiento 6: Vendor registration error mitigation and draft in session.""" + self.client.login(username='admin_user', password='password123') + # Submit invalid vendor form (RUT invalid - fails Modulo 11 check) + response = self.client.post('/gestion/proveedores/nuevo/', { + 'rut': '12345678-0', + 'name': 'Vendor Test Draft', + 'payment_terms': 30 + }) + self.assertEqual(response.status_code, 200) + self.assertIn('vendor_draft', self.client.session) + + def test_req_7_is_reimbursement_logic(self): + """Test Requerimiento 7: Erogación rendición vs centro I+D+i.""" + expense = Expense.objects.create( + project=self.project_idi, + category=self.category_office, + is_reimbursement=False, + amount_clp=50000, + date=date.today(), + description='Gasto I+D+i directo', + user=self.admin + ) + self.assertFalse(expense.is_reimbursement) + + # Reimbursement expense + expense_reimb = Expense.objects.create( + project=self.project_idi, + category=self.category_office, + is_reimbursement=True, + responsible_person=self.pm_user, + amount_clp=30000, + date=date.today(), + description='Rendición de pasajes', + user=self.admin + ) + self.assertTrue(expense_reimb.is_reimbursement) + self.assertEqual(expense_reimb.responsible_person, self.pm_user) + + def test_req_8_9_10_project_detail_and_phase_balances(self): + """Test Requerimiento 8, 9, 10: Project detail, progressive creation, and phase balances (+ / -).""" + phase1 = ProjectPhase.objects.create( + project=self.project_idi, + phase_number=1, + name='Fase 1: Prototipado', + planned_amount_clp=5000000, + income_amount_clp=6000000, + expense_amount_clp=2000000 + ) + self.assertEqual(phase1.balance_clp, Decimal('4000000')) # Positive balance +$4.000.000 + + phase2 = ProjectPhase.objects.create( + project=self.project_idi, + phase_number=2, + name='Fase 2: Pruebas Terreno', + planned_amount_clp=3000000, + income_amount_clp=1000000, + expense_amount_clp=2500000 + ) + self.assertEqual(phase2.balance_clp, Decimal('-1500000')) # Negative balance -$1.500.000 + + self.client.login(username='admin_user', password='password123') + res = self.client.get(f'/proyectos/{self.project_idi.id}/') + self.assertEqual(res.status_code, 200) + self.assertIn('Fases de Pago e Hitos del Proyecto', res.content.decode()) + + def test_req_11_12_13_categories_taxonomy(self): + """Test Requerimiento 11, 12, 13: Talent category 7, Office Rental, and Specialist Courses.""" + # Req 11 + self.assertEqual(SpecialistCategoryEnum.CAT_7_SOFTWARE_DATOS_IA_CLOUD, '7') + self.assertEqual(SpecialistCategoryEnum.CAT_7_SOFTWARE_DATOS_IA_CLOUD.label, '7. Software, Datos, IA y Cloud') + + # Req 12 + self.assertEqual(self.category_office.code, '2301') + + # Req 13 + self.assertEqual(self.category_courses.code, '4201') + + def test_req_14_vehicle_category_strict_combustible(self): + """Test Requerimiento 14: Vehicle log applies ONLY to combustible category.""" + exp_combustible = Expense.objects.create( + project=self.project_idi, + category=self.category_combustible, + amount_clp=40000, + date=date.today(), + description='Carga Combustible Copec', + user=self.admin + ) + self.assertTrue(exp_combustible.is_vehicle_category()) + + exp_office = Expense.objects.create( + project=self.project_idi, + category=self.category_office, + amount_clp=150000, + date=date.today(), + description='Pago Arriendo Oficina', + user=self.admin + ) + self.assertFalse(exp_office.is_vehicle_category()) diff --git a/expenses/urls.py b/expenses/urls.py index 52a8988..0f5dd57 100644 --- a/expenses/urls.py +++ b/expenses/urls.py @@ -12,6 +12,12 @@ urlpatterns = [ path('gastos//eliminar/', views.expense_delete, name='expense_delete'), path('gastos/documento//eliminar/', views.delete_document, name='delete_document'), + # Rendiciones Agrupadas (Informes de Viaje / Faena) + path('rendiciones/', views.expense_report_list, name='expense_report_list'), + path('rendiciones/nueva/', views.expense_report_create, name='expense_report_create'), + path('rendiciones//', views.expense_report_detail, name='expense_report_detail'), + path('rendiciones//accion//', views.expense_report_action, name='expense_report_action'), + # Flujo de aprobación y Cuentas por Pagar path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'), path('gastos//aprobar//', views.approve_expense, name='approve_expense'), @@ -21,6 +27,8 @@ urlpatterns = [ path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'), path('proyectos/', views.project_list, name='project_list'), + path('proyectos//', views.project_detail, name='project_detail'), + path('proyectos//fase/nueva/', views.project_add_phase, name='project_add_phase'), path('gestion/', views.gestion_panel, name='gestion_panel'), path('gestion/configuracion/', views.settings_edit, name='settings_edit'), @@ -40,4 +48,9 @@ urlpatterns = [ path('gestion/responsables/nuevo/', views.responsable_create, name='responsable_create'), path('gestion/responsables//editar/', views.responsable_edit, name='responsable_edit'), path('gestion/responsables//eliminar/', views.responsable_delete, name='responsable_delete'), + + # SII AJAX Endpoints + path('api/sii/validate-rut/', views.api_sii_validate_rut, name='api_sii_validate_rut'), + path('api/sii/taxpayer-info/', views.api_sii_taxpayer_info, name='api_sii_taxpayer_info'), + path('api/sii/calculate-taxes/', views.api_sii_calculate_taxes, name='api_sii_calculate_taxes'), ] diff --git a/expenses/views.py b/expenses/views.py index 134e854..02c819e 100644 --- a/expenses/views.py +++ b/expenses/views.py @@ -1,10 +1,13 @@ import csv import io +from decimal import Decimal from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required +from django.views.decorators.cache import cache_page from django.contrib.auth import login, authenticate, logout from django.contrib import messages from django.db.models import Sum, Count, F +from django.db import transaction from django.utils import timezone from datetime import date from django.http import JsonResponse, HttpResponse @@ -37,6 +40,7 @@ def logout_view(request): @login_required +@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad) def dashboard(request): today = date.today() first_day_of_month = today.replace(day=1) @@ -49,18 +53,21 @@ def dashboard(request): total=Sum('amount_clp') )['total'] or 0 - project_count = Project.objects.count() + # Requerimiento 2: Conteo exacto de proyectos reales (excluyendo portafolios) + from .models import ProjectStatusEnum + real_projects = Project.objects.filter(parent__parent__isnull=False) + project_count_active = real_projects.filter(status=ProjectStatusEnum.ACTIVO).count() + project_count_paused = real_projects.filter(status=ProjectStatusEnum.PAUSADA).count() + project_count_completed = real_projects.filter(status=ProjectStatusEnum.TERMINADO).count() + project_count_draft = real_projects.filter(status=ProjectStatusEnum.BORRADOR).count() + project_count_total = real_projects.count() + today_count = Expense.objects.filter(date=today).count() recent_expenses = Expense.objects.select_related( 'project', 'category', 'user', 'responsible_person' ).order_by('-date')[:10] - # Presupuesto Global - setting, _ = SystemSetting.objects.get_or_create(id=1) - global_budget = setting.global_budget - remaining_budget = global_budget - total_all_clp - # Chart Data: Gastos por Categoría cat_expenses = Expense.objects.values('category__name').annotate(total=Sum('amount_clp')).order_by('-total') category_labels = [item['category__name'] or 'Sin Categoría' for item in cat_expenses] @@ -74,11 +81,13 @@ def dashboard(request): context = { 'total_month': total_month_clp, 'total_all': total_all_clp, - 'project_count': project_count, + 'project_count_active': project_count_active, + 'project_count_paused': project_count_paused, + 'project_count_completed': project_count_completed, + 'project_count_draft': project_count_draft, + 'project_count_total': project_count_total, 'today_count': today_count, 'recent_expenses': recent_expenses, - 'global_budget': global_budget, - 'remaining_budget': remaining_budget, 'category_labels_json': json.dumps(category_labels), 'category_data_json': json.dumps(category_data), 'project_labels_json': json.dumps(project_labels), @@ -111,6 +120,7 @@ def expense_list(request): @login_required +@transaction.atomic # Integridad de datos def expense_create(request): # Only allow creation if user is admin or allowed to if request.method == 'POST': @@ -419,13 +429,49 @@ def export_bank_transfer_csv(request): @login_required def project_list(request): + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups + is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups + projects = Project.objects.annotate( expense_count=Count('expenses'), total_expenses=Sum('expenses__amount_clp') - ).select_related('manager') + ).select_related('manager', 'category', 'parent').prefetch_related('phases', 'expenses').filter( + parent__parent__isnull=False + ).order_by('-created_at') + + # If PM and not admin, show managed or assigned projects + from django.db.models import Q + if is_pm and not is_admin: + projects = projects.filter(Q(manager=request.user) | Q(created_by=request.user)) + + # Filters + status_filter = request.GET.get('status') + search_query = request.GET.get('search') + + if status_filter: + projects = projects.filter(status=status_filter) + if search_query: + projects = projects.filter(Q(name__icontains=search_query) | Q(code__icontains=search_query)) + + + # Requerimiento 2: Conteos exactos por estado + from .models import ProjectStatusEnum + project_count_active = projects.filter(status=ProjectStatusEnum.ACTIVO).count() + project_count_paused = projects.filter(status=ProjectStatusEnum.PAUSADA).count() + project_count_completed = projects.filter(status=ProjectStatusEnum.TERMINADO).count() + project_count_draft = projects.filter(status=ProjectStatusEnum.BORRADOR).count() + project_count_total = projects.count() context = { 'projects': projects, + 'is_admin': is_admin, + 'is_pm': is_pm, + 'project_count_active': project_count_active, + 'project_count_paused': project_count_paused, + 'project_count_completed': project_count_completed, + 'project_count_draft': project_count_draft, + 'project_count_total': project_count_total, } return render(request, 'expenses/project_list.html', context) @@ -467,34 +513,125 @@ def settings_edit(request): @login_required -@role_required('Administrador') +def project_detail(request, pk): + """ + Requerimiento 9 & 10: Detalle del Proyecto con Presupuesto asignado, Forma de pago, + Ingresos, Egresos, Saldo neto y Fases de pago con saldo a favor (+ / -). + """ + project = get_object_or_404( + Project.objects.select_related('manager', 'category', 'parent', 'created_by') + .prefetch_related('phases', 'expenses__vendor', 'expenses__documents', 'purchase_orders'), + pk=pk + ) + + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups + is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups + + phases = project.phases.all() + expenses = project.expenses.select_related('vendor', 'category', 'user').order_by('-date') + + # Calculations + total_income = project.total_income_clp or Decimal('0.00') + total_expenses = project.total_expenses_clp or Decimal('0.00') + net_balance = project.balance_clp + budget_balance = project.budget_balance_clp + + context = { + 'project': project, + 'phases': phases, + 'expenses': expenses, + 'total_income': total_income, + 'total_expenses': total_expenses, + 'net_balance': net_balance, + 'budget_balance': budget_balance, + 'is_admin': is_admin, + 'is_pm': is_pm, + } + return render(request, 'expenses/project_detail.html', context) + + +@login_required +def project_add_phase(request, pk): + """ + Requerimiento 10: Agregar / actualizar fases de pago para un proyecto. + """ + project = get_object_or_404(Project, pk=pk) + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups + if not (is_admin or project.manager == request.user or 'Director de Proyecto' in user_groups): + messages.error(request, 'No tienes permisos para modificar las fases de este proyecto.') + return redirect('project_detail', pk=pk) + + from .forms import ProjectPhaseForm + if request.method == 'POST': + form = ProjectPhaseForm(request.POST) + if form.is_valid(): + phase = form.save(commit=False) + phase.project = project + phase.save() + messages.success(request, f"Fase {phase.phase_number} ({phase.name}) agregada al proyecto.") + return redirect('project_detail', pk=pk) + else: + next_num = project.phases.count() + 1 + form = ProjectPhaseForm(initial={'phase_number': next_num}) + + return render(request, 'expenses/project_phase_form.html', {'form': form, 'project': project}) + + +@login_required def project_create(request): + """ + Requerimiento 8: Creación progresiva para director de proyectos y RBAC granular. + """ + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups + is_pm = 'Director de Proyecto' in user_groups or 'Project Manager' in user_groups or is_admin + + if not is_pm: + messages.error(request, 'No tienes permisos para crear proyectos.') + return redirect('project_list') + if request.method == 'POST': form = ProjectForm(request.POST) if form.is_valid(): project = form.save(commit=False) project.created_by = request.user + if not project.manager and is_pm and not is_admin: + project.manager = request.user project.save() - messages.success(request, 'Proyecto creado exitosamente.') - return redirect('gestion_panel') + messages.success(request, f"Proyecto {project.code} - {project.name} creado exitosamente.") + return redirect('project_detail', pk=project.pk) else: - form = ProjectForm() - return render(request, 'expenses/project_form.html', {'form': form}) + # Default code generation + count = Project.objects.count() + 1 + default_code = f"PROY-2026-{count:03d}" + form = ProjectForm(initial={'code': default_code, 'manager': request.user}) + + return render(request, 'expenses/project_form.html', {'form': form, 'title': 'Nuevo Proyecto / Centro de Costo'}) @login_required -@role_required('Administrador') def project_edit(request, pk): project = get_object_or_404(Project, pk=pk) + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups + is_pm = project.manager == request.user or project.created_by == request.user or 'Director de Proyecto' in user_groups or is_admin + + if not is_pm: + messages.error(request, 'No tienes permisos para editar este proyecto.') + return redirect('project_list') + if request.method == 'POST': form = ProjectForm(request.POST, instance=project) if form.is_valid(): form.save() - messages.success(request, 'Proyecto actualizado exitosamente.') - return redirect('gestion_panel') + messages.success(request, f"Proyecto {project.code} actualizado exitosamente.") + return redirect('project_detail', pk=project.pk) else: form = ProjectForm(instance=project) - return render(request, 'expenses/project_form.html', {'form': form}) + + return render(request, 'expenses/project_form.html', {'form': form, 'project': project, 'title': f'Editar Proyecto: {project.code}'}) @login_required @@ -504,7 +641,7 @@ def project_delete(request, pk): if request.method == 'POST': project.delete() messages.success(request, 'Proyecto eliminado.') - return redirect('gestion_panel') + return redirect('project_list') @login_required @@ -547,32 +684,59 @@ def category_delete(request, pk): @login_required -@role_required('Administrador') def vendor_create(request): + """ + Requerimiento 6: Plan de mitigación de errores para registro de proveedores. + Preserva datos en borrador (session/context) si ocurre un fallo o error de validación. + """ + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups + if not is_admin: + messages.error(request, 'No tienes permisos para registrar proveedores.') + return redirect('dashboard') + + draft_data = request.session.get('vendor_draft', {}) + if request.method == 'POST': form = VendorForm(request.POST) if form.is_valid(): - form.save() - messages.success(request, 'Proveedor creado exitosamente.') + vendor = form.save() + if 'vendor_draft' in request.session: + del request.session['vendor_draft'] + messages.success(request, f"Proveedor {vendor.rut} - {vendor.name} registrado exitosamente.") return redirect('gestion_panel') + else: + # Save POST data into session as draft mitigation + request.session['vendor_draft'] = request.POST.dict() + messages.warning(request, "Error en el formulario. Los datos ingresados han sido preservados para prevenir pérdida de información.") else: - form = VendorForm() - return render(request, 'expenses/vendor_form.html', {'form': form}) + initial = draft_data if draft_data else None + form = VendorForm(initial=initial) + + return render(request, 'expenses/vendor_form.html', {'form': form, 'title': 'Nuevo Proveedor', 'has_draft': bool(draft_data)}) @login_required -@role_required('Administrador') def vendor_edit(request, pk): vendor = get_object_or_404(Vendor, pk=pk) + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups + if not is_admin: + messages.error(request, 'No tienes permisos para editar proveedores.') + return redirect('dashboard') + if request.method == 'POST': form = VendorForm(request.POST, instance=vendor) if form.is_valid(): form.save() - messages.success(request, 'Proveedor actualizado exitosamente.') + messages.success(request, f"Proveedor {vendor.rut} actualizado exitosamente.") return redirect('gestion_panel') + else: + messages.warning(request, "Error de validación al actualizar el proveedor. Se conservan los cambios parciales.") else: form = VendorForm(instance=vendor) - return render(request, 'expenses/vendor_form.html', {'form': form}) + + return render(request, 'expenses/vendor_form.html', {'form': form, 'title': f'Editar Proveedor: {vendor.rut}'}) @login_required @@ -706,3 +870,137 @@ def delete_document(request, pk): doc.delete() return JsonResponse({'success': True}) return JsonResponse({'success': False, 'error': 'Método no permitido.'}, status=405) + + +# ----------------- EXPENSE REPORTS (RENDICIONES AGRUPADAS) ----------------- +from .models import ExpenseReport, VehicleLog, ExpenseReportStatusEnum +from .forms import ExpenseReportForm, VehicleLogForm +from .services.sii import SIIService + +@login_required +def expense_report_list(request): + reports = ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses').all() + + # Filter by user if not admin + user_groups = request.user.groups.values_list('name', flat=True) + is_admin = request.user.is_superuser or 'Administrador' in user_groups or 'Administrador Finanzas' in user_groups + + status_filter = request.GET.get('status') + if status_filter: + reports = reports.filter(status=status_filter) + + return render(request, 'expenses/expense_report_list.html', { + 'reports': reports, + 'selected_status': status_filter, + 'is_admin': is_admin, + }) + +@login_required +def expense_report_create(request): + if request.method == 'POST': + form = ExpenseReportForm(request.POST) + if form.is_valid(): + rep = form.save(commit=False) + rep.user = request.user + count = ExpenseReport.objects.count() + 1 + rep.report_code = f"EXP-REP-2026-{count:04d}" + rep.status = ExpenseReportStatusEnum.BORRADOR + rep.save() + messages.success(request, f"Informe de Rendición {rep.report_code} creado exitosamente. Ahora puede adjuntar gastos.") + return redirect('expense_report_detail', pk=rep.pk) + else: + form = ExpenseReportForm() + return render(request, 'expenses/expense_report_form.html', {'form': form, 'title': 'Nuevo Informe de Rendición de Gastos'}) + +@login_required +def expense_report_detail(request, pk): + report = get_object_or_404(ExpenseReport.objects.select_related('user', 'project', 'approved_by').prefetch_related('expenses__vendor', 'expenses__category', 'expenses__documents', 'expenses__vehicle_log'), pk=pk) + + # Available standalone expenses for this project and user + available_expenses = Expense.objects.filter( + report__isnull=True, + user=report.user, + project=report.project + ).order_by('-date') + + return render(request, 'expenses/expense_report_detail.html', { + 'report': report, + 'available_expenses': available_expenses, + }) + +@login_required +def expense_report_action(request, pk, action): + report = get_object_or_404(ExpenseReport, pk=pk) + if request.method == 'POST': + if action == 'submit': + report.status = ExpenseReportStatusEnum.ENVIADO + messages.success(request, f"Rendición {report.report_code} enviada a revisión y aprobación de Finanzas.") + elif action == 'approve': + report.status = ExpenseReportStatusEnum.APROBADO + report.approved_by = request.user + report.approved_at = timezone.now() + # Also elevate all child expenses to SOLICITADO_FINANZAS + report.expenses.update(approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS, elevated_by=request.user) + messages.success(request, f"Rendición {report.report_code} aprobada por Finanzas.") + elif action == 'reimburse': + report.status = ExpenseReportStatusEnum.REEMBOLSADO + report.expenses.update(approval_status=ApprovalStatusEnum.LIBERADO_PAGADO, paid_by=request.user, paid_at=timezone.now()) + messages.success(request, f"Rendición {report.report_code} marcada como Reembolsada / Pagada.") + elif action == 'observe': + report.status = ExpenseReportStatusEnum.OBSERVADO + report.notes += f"\nObservación ({timezone.now().strftime('%d/%m/%Y')}) por {request.user.username}: " + request.POST.get('reason', '') + messages.warning(request, f"Rendición {report.report_code} observada.") + elif action == 'add_expense': + expense_id = request.POST.get('expense_id') + if expense_id: + exp = Expense.objects.filter(id=expense_id, user=report.user).first() + if exp: + exp.report = report + exp.save(update_fields=['report']) + messages.success(request, f"Gasto {exp.expense_code} añadido a la rendición.") + elif action == 'remove_expense': + expense_id = request.POST.get('expense_id') + if expense_id: + exp = Expense.objects.filter(id=expense_id, report=report).first() + if exp: + exp.report = None + exp.save(update_fields=['report']) + messages.success(request, f"Gasto {exp.expense_code} desvinculado de la rendición.") + + report.save() + return redirect('expense_report_detail', pk=pk) + +# API / AJAX helper for SII RUT check, taxpayer info & tax calculation +@login_required +def api_sii_validate_rut(request): + rut = request.GET.get('rut', '') + is_valid, formatted = SIIService.validate_rut(rut) + return JsonResponse({'valid': is_valid, 'formatted': formatted}) + +@login_required +def api_sii_taxpayer_info(request): + """ + Requerimiento 4: Información e integración con la API del SII. + """ + rut = request.GET.get('rut', '') + info = SIIService.get_taxpayer_info(rut) + return JsonResponse(info) + +@login_required +def api_sii_calculate_taxes(request): + net_str = request.GET.get('net_amount', '0').replace('.', '').replace(',', '.') + tax_type = request.GET.get('tax_type', 'IVA_19') + try: + net = Decimal(net_str) + except Exception: + net = Decimal('0') + res = SIIService.calculate_taxes(net, tax_type) + return JsonResponse({ + 'tax_type': res['tax_type'], + 'net_amount': float(res['net_amount']), + 'tax_rate_percent': float(res.get('tax_rate_percent', 19.0)), + 'tax_amount': float(res['tax_amount']), + 'total_amount': float(res['total_amount']), + 'description': res['description'] + }) + diff --git a/fix_expense_form.py b/fix_expense_form.py new file mode 100644 index 0000000..aca1501 --- /dev/null +++ b/fix_expense_form.py @@ -0,0 +1,12 @@ +import re + +with open('templates/expenses/expense_form.html', 'r', encoding='utf-8') as f: + content = f.read() + +# 1. Add x-data to the form +content = content.replace('
', '') + +# 2. Add x-model to is_reimbursement and x-show to responsible person +# Search for {{ form.is_reimbursement }} +content = content.replace('{{ form.is_reimbursement }}', '{{ form.is_reimbursement }}') +# Wait, I can just inject AlpineJS attributes using django-widget-tweaks or by replacing the input html, but since it's rendered via {{ form.xxx }}, I need to modify `forms.py` to add `x-model` or wrap them in Alpine elements. diff --git a/patch_html.py b/patch_html.py new file mode 100644 index 0000000..80e5c3b --- /dev/null +++ b/patch_html.py @@ -0,0 +1,98 @@ +import re + +with open('templates/expenses/expense_form.html', 'r', encoding='utf-8') as f: + content = f.read() + +# 1. Add x-data +content = content.replace('', '') + +# 2. Add x-show to responsible person container +content = content.replace('
', '
') + +# Replace the giant vanilla JS around line 875 +script_pattern = re.compile(r'', re.DOTALL) + +alpine_script = """ + + +""" + +content = script_pattern.sub(alpine_script, content) + +with open('templates/expenses/expense_form.html', 'w', encoding='utf-8') as f: + f.write(content) diff --git a/patch_models.py b/patch_models.py new file mode 100644 index 0000000..d5c51a8 --- /dev/null +++ b/patch_models.py @@ -0,0 +1,88 @@ +import re + +with open('expenses/models.py', 'r', encoding='utf-8') as f: + content = f.read() + +# Add TimeStampedModel and CatalogModel right before Project +abstracts = """ +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 + +""" +if 'class TimeStampedModel' not in content: + content = content.replace("class Project(models.Model):", abstracts + "class Project(CatalogModel):") + # Clean Project + content = content.replace(" code = models.CharField(max_length=50, unique=True, verbose_name=\"Código\", default='PROY-000')\n", "") + content = content.replace(" name = models.CharField(max_length=200, verbose_name=\"Nombre del Proyecto/Centro\")\n", "") + content = content.replace(" description = models.TextField(blank=True, verbose_name=\"Descripción\")\n", "") + content = content.replace(" created_at = models.DateTimeField(auto_now_add=True, verbose_name=\"Fecha de Creación\")\n", "") + content = content.replace(" updated_at = models.DateTimeField(auto_now=True, verbose_name=\"Última Modificación\")\n", "") + + # Clean Category + content = content.replace("class Category(models.Model):", "class Category(CatalogModel):") + content = content.replace(" code = models.CharField(max_length=10, unique=True, verbose_name=\"Código de Categoría\")\n", "") + content = content.replace(" name = models.CharField(max_length=100, verbose_name=\"Nombre de Categoría\")\n", "") + content = content.replace(" description = models.TextField(blank=True, verbose_name=\"Descripción\")\n", "") + + # Clean Vendor + content = content.replace("class Vendor(models.Model):", "class Vendor(TimeStampedModel):") + content = content.replace(" created_at = models.DateTimeField(auto_now_add=True)\n updated_at = models.DateTimeField(auto_now=True)\n", "") + + # Clean Expense + content = content.replace("class Expense(models.Model):", "class Expense(TimeStampedModel):") + # We must only replace the first occurrence of these dates inside Expense, but replace is global. + # So we do a regex to target Expense block. + # Actually Expense is the only one with exactly these lines together at the end. + content = re.sub(r' created_at = models.DateTimeField\(auto_now_add=True\)\n updated_at = models.DateTimeField\(auto_now=True\)\n\n def save', ' def save', content) + +encap = """ + 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 save(self, *args, **kwargs):""" +content = content.replace(" def save(self, *args, **kwargs):", encap) + +proxy = """ +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" +""" +if 'class Reimbursement' not in content: + content += proxy + +with open('expenses/models.py', 'w', encoding='utf-8') as f: + f.write(content) diff --git a/patch_views.py b/patch_views.py new file mode 100644 index 0000000..0ef9787 --- /dev/null +++ b/patch_views.py @@ -0,0 +1,17 @@ +with open('expenses/views.py', 'r', encoding='utf-8') as f: + content = f.read() + +# Add transaction and cache imports +if 'from django.db import transaction' not in content: + content = content.replace("from django.db.models import Sum, Count, F", "from django.db.models import Sum, Count, F\nfrom django.db import transaction") +if 'from django.views.decorators.cache import cache_page' not in content: + content = content.replace("from django.contrib.auth.decorators import login_required", "from django.contrib.auth.decorators import login_required\nfrom django.views.decorators.cache import cache_page") + +# Decorate dashboard with cache_page +content = content.replace("@login_required\ndef dashboard(request):", "@login_required\n@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad)\ndef dashboard(request):") + +# Add @transaction.atomic to expense_create +content = content.replace("@login_required\ndef expense_create(request):", "@login_required\n@transaction.atomic # Integridad de datos\ndef expense_create(request):") + +with open('expenses/views.py', 'w', encoding='utf-8') as f: + f.write(content) diff --git a/payroll/__init__.py b/payroll/__init__.py new file mode 100644 index 0000000..1e06f06 --- /dev/null +++ b/payroll/__init__.py @@ -0,0 +1 @@ +# Payroll app diff --git a/payroll/admin.py b/payroll/admin.py new file mode 100644 index 0000000..01ec366 --- /dev/null +++ b/payroll/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry + +@admin.register(EmployeeProfile) +class EmployeeProfileAdmin(admin.ModelAdmin): + list_display = ('rut', 'first_name', 'last_name', 'position', 'contract_type', 'work_schedule', 'base_salary_clp', 'afp', 'health_system', 'is_active') + search_fields = ('rut', 'first_name', 'last_name', 'position') + list_filter = ('contract_type', 'work_schedule', 'afp', 'health_system', 'is_active') + +@admin.register(SalarySlip) +class SalarySlipAdmin(admin.ModelAdmin): + list_display = ('employee', 'month', 'year', 'total_gross_income', 'net_salary', 'total_company_cost', 'status', 'signed_at') + search_fields = ('employee__rut', 'employee__first_name', 'employee__last_name') + list_filter = ('year', 'month', 'status') + +@admin.register(PayrollCentralizationEntry) +class PayrollCentralizationEntryAdmin(admin.ModelAdmin): + list_display = ('month', 'year', 'account_code', 'total_debit', 'total_credit', 'is_balanced', 'status') + list_filter = ('year', 'month', 'is_balanced') diff --git a/payroll/apps.py b/payroll/apps.py new file mode 100644 index 0000000..9196389 --- /dev/null +++ b/payroll/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class PayrollConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payroll' + verbose_name = 'Remuneraciones y RRHH (BUK)' diff --git a/payroll/forms.py b/payroll/forms.py new file mode 100644 index 0000000..24ed4e7 --- /dev/null +++ b/payroll/forms.py @@ -0,0 +1,92 @@ +from django import forms +from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry +from decimal import Decimal + +class EmployeeProfileForm(forms.ModelForm): + hire_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + label='Fecha de Contratación' + ) + base_salary_clp = forms.DecimalField( + label='Sueldo Base Mensual (CLP)', + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0', 'required': True}) + ) + colacion_clp = forms.DecimalField( + label='Asignación Colación (CLP)', + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'}) + ) + movilizacion_clp = forms.DecimalField( + label='Asignación Movilización (CLP)', + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'}) + ) + + class Meta: + model = EmployeeProfile + fields = [ + 'rut', 'first_name', 'last_name', 'email', 'phone', 'address', + 'position', 'department', 'contract_type', 'work_schedule', 'hire_date', + 'afp', 'afp_rate', 'health_system', 'health_institution', 'health_plan_uf', + 'has_afc', 'base_salary_clp', 'colacion_clp', 'movilizacion_clp', + 'bank_name', 'account_type', 'account_number', 'is_active' + ] + widgets = { + 'rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 12.345.678-9', 'pattern': r'^(\d{7,8}-[\dkK])$', 'required': True, 'title': 'RUT sin puntos y con guion'}), + 'first_name': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'minlength': '2'}), + 'last_name': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'minlength': '2'}), + 'email': forms.EmailInput(attrs={'class': 'form-control', 'required': True}), + 'phone': forms.TextInput(attrs={'class': 'form-control', 'pattern': r'^\+?\d{9,15}$', 'title': 'Teléfono válido (ej: +56912345678)'}), + 'address': forms.TextInput(attrs={'class': 'form-control'}), + 'position': forms.TextInput(attrs={'class': 'form-control', 'required': True}), + 'department': forms.TextInput(attrs={'class': 'form-control', 'required': True}), + 'contract_type': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'work_schedule': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'afp': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'afp_rate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0', 'max': '100', 'required': True}), + 'health_system': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'health_institution': forms.TextInput(attrs={'class': 'form-control'}), + 'health_plan_uf': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.0001', 'min': '0'}), + 'bank_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}), + 'account_type': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'account_number': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'pattern': r'^\d+$', 'title': 'Solo números'}), + } + + +class SalarySlipGenerateForm(forms.Form): + month = forms.ChoiceField( + choices=[(i, f"{i:02d} - {m}") for i, m in enumerate([ + "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre" + ], 1)], + label="Mes a Liquidar", + widget=forms.Select(attrs={'class': 'form-control'}) + ) + year = forms.IntegerField( + initial=2026, + label="Año", + widget=forms.NumberInput(attrs={'class': 'form-control'}) + ) + employee = forms.ModelChoiceField( + queryset=EmployeeProfile.objects.filter(is_active=True), + required=False, + empty_label="--- Todos los colaboradores activos (Cálculo Masivo) ---", + label="Colaborador Específico", + widget=forms.Select(attrs={'class': 'form-control'}) + ) + overtime_hours = forms.DecimalField( + initial=Decimal('0'), + required=False, + label="Horas Extras 50% (Aplica a cálculo individual)", + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.5', 'min': '0'}) + ) + rd_bonus = forms.DecimalField( + initial=Decimal('0'), + required=False, + label="Bono I+D+i / Desempeño (CLP)", + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '10000', 'min': '0'}) + ) + other_deductions = forms.DecimalField( + initial=Decimal('0'), + required=False, + label="Otros Descuentos / Anticipos (CLP)", + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'}) + ) diff --git a/payroll/migrations/0001_initial.py b/payroll/migrations/0001_initial.py new file mode 100644 index 0000000..848e6c3 --- /dev/null +++ b/payroll/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 6.1 on 2026-09-07 02:36 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EmployeeProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rut', models.CharField(max_length=20, unique=True, verbose_name='RUT Trabajador')), + ('first_name', models.CharField(max_length=100, verbose_name='Nombres')), + ('last_name', models.CharField(max_length=100, verbose_name='Apellidos')), + ('email', models.EmailField(max_length=254, verbose_name='Correo Electrónico')), + ('phone', models.CharField(blank=True, max_length=30, verbose_name='Teléfono Móvil')), + ('address', models.CharField(blank=True, max_length=255, verbose_name='Dirección Particular')), + ('position', models.CharField(default='Ingeniero de Desarrollo', max_length=150, verbose_name='Cargo / Especialidad I+D+i')), + ('department', models.CharField(default='I+D+i y Operaciones', max_length=100, verbose_name='Departamento / Área')), + ('contract_type', models.CharField(choices=[('INDEFINIDO', 'Contrato Indefinido'), ('PLAZO_FIJO', 'Contrato a Plazo Fijo'), ('HONORARIOS', 'Prestación de Servicios (Honorarios)'), ('PRACTICA', 'Práctica Profesional')], default='INDEFINIDO', max_length=50, verbose_name='Tipo de Contrato')), + ('work_schedule', models.CharField(choices=[('40H_LEY_21561', '40 Horas Semanales (Ley 21.561)'), ('44H', '44 Horas Semanales'), ('45H', '45 Horas Semanales (Antiguo)'), ('30H_PART_TIME', 'Part-time 30 Horas'), ('20H_PART_TIME', 'Part-time 20 Horas'), ('ART_22_INC_2', 'Sin Fiscalización Superior (Art. 22 Inc. 2)')], default='40H_LEY_21561', max_length=50, verbose_name='Jornada Laboral')), + ('hire_date', models.DateField(verbose_name='Fecha de Contratación')), + ('afp', models.CharField(choices=[('HABITAT', 'AFP Habitat (11.27%)'), ('CUPRUM', 'AFP Cuprum (11.44%)'), ('MODELO', 'AFP Modelo (10.58%)'), ('PLANVITAL', 'AFP PlanVital (11.16%)'), ('PROVIDA', 'AFP ProVida (11.45%)'), ('CAPITAL', 'AFP Capital (11.44%)'), ('UNO', 'AFP Uno (10.49%)')], default='MODELO', max_length=50, verbose_name='AFP Previsional')), + ('afp_rate', models.DecimalField(decimal_places=2, default=10.58, max_digits=5, verbose_name='Tasa Cotización AFP (%)')), + ('health_system', models.CharField(choices=[('FONASA', 'FONASA (7.00%)'), ('ISAPRE', 'ISAPRE (Pactado UF / %)')], default='FONASA', max_length=50, verbose_name='Sistema de Salud')), + ('health_institution', models.CharField(blank=True, default='FONASA', max_length=100, verbose_name='Institución Salud (FONASA / Isapre)')), + ('health_plan_uf', models.DecimalField(decimal_places=4, default=0, max_digits=6, verbose_name='Plan Salud Pactado (UF - solo Isapre)')), + ('health_rate', models.DecimalField(decimal_places=2, default=7.0, max_digits=5, verbose_name='Tasa Legal Salud (%)')), + ('has_afc', models.BooleanField(default=True, verbose_name='Afecto a Seguro de Cesantía (AFC)')), + ('base_salary_clp', models.DecimalField(decimal_places=2, default=1500000, max_digits=12, verbose_name='Sueldo Base Mensual (CLP)')), + ('colacion_clp', models.DecimalField(decimal_places=2, default=65000, max_digits=10, verbose_name='Asignación Colación (No Imponible)')), + ('movilizacion_clp', models.DecimalField(decimal_places=2, default=65000, max_digits=10, verbose_name='Asignación Movilización (No Imponible)')), + ('bank_name', models.CharField(default='Banco Santander-Chile', max_length=100, verbose_name='Banco Receptor')), + ('account_type', models.CharField(choices=[('CORRIENTE', 'Cuenta Corriente'), ('VISTA_RUT', 'Cuenta Vista / Cuenta RUT'), ('AHORRO', 'Cuenta de Ahorro')], default='CORRIENTE', max_length=50, verbose_name='Tipo de Cuenta')), + ('account_number', models.CharField(default='00000000', max_length=50, verbose_name='Número de Cuenta Bancaria')), + ('is_active', models.BooleanField(default=True, verbose_name='Trabajador Activo')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_profile', to=settings.AUTH_USER_MODEL, verbose_name='Usuario del Sistema')), + ], + options={ + 'verbose_name': 'Perfil de Colaborador', + 'verbose_name_plural': 'Nómina de Colaboradores', + 'ordering': ['last_name', 'first_name'], + }, + ), + migrations.CreateModel( + name='PayrollCentralizationEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('month', models.IntegerField(verbose_name='Mes')), + ('year', models.IntegerField(verbose_name='Año')), + ('entry_date', models.DateField(verbose_name='Fecha del Asiento')), + ('glosa', models.CharField(default='Centralización de Remuneraciones y Leyes Sociales', max_length=255, verbose_name='Glosa Contable')), + ('account_code', models.CharField(default='5101', max_length=50, verbose_name='Cuenta Contable Débito (5101)')), + ('account_name', models.CharField(default='Gastos en Personal y Cargas Sociales I+D+i', max_length=150, verbose_name='Nombre Cuenta Débito')), + ('total_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haberes Imponibles')), + ('total_non_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haberes No Imponibles')), + ('total_employer_charges', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Cargas Sociales Empleador (SIS/ACHS/AFC)')), + ('total_net_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Sueldos Líquidos por Pagar (Banco)')), + ('total_previred_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Cotizaciones por Pagar (Previred)')), + ('total_sii_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Retención Impuesto Único SII')), + ('total_debit', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Debe (CLP)')), + ('total_credit', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haber (CLP)')), + ('is_balanced', models.BooleanField(default=True, verbose_name='Asiento Cuadrado (Debe == Haber)')), + ('status', models.CharField(default='CONTABILIZADO', max_length=30, verbose_name='Estado Asiento')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Asiento de Centralización de Remuneraciones', + 'verbose_name_plural': 'Asientos de Centralización (Cuenta 5101)', + 'ordering': ['-year', '-month'], + 'unique_together': {('month', 'year')}, + }, + ), + migrations.CreateModel( + name='SalarySlip', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slip_uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('month', models.IntegerField(verbose_name='Mes (1-12)')), + ('year', models.IntegerField(verbose_name='Año (ej. 2026)')), + ('issue_date', models.DateField(verbose_name='Fecha de Emisión')), + ('days_worked', models.IntegerField(default=30, verbose_name='Días Trabajados')), + ('base_salary', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Sueldo Base')), + ('overtime_hours_50', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Horas Extras (50%)')), + ('overtime_amount_50', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Monto Horas Extras 50%')), + ('legal_gratification', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Gratificación Legal (Art. 50)')), + ('rd_bonus', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Bonos de Rendimiento I+D+i')), + ('other_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Otros Haberes Imponibles')), + ('total_taxable_income', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Haberes Imponibles')), + ('allowance_lunch', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Asignación Colación')), + ('allowance_commute', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Asignación Movilización')), + ('expense_reimbursements', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Reembolsos de Gastos en Terreno')), + ('total_non_taxable_income', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total Haberes No Imponibles')), + ('total_gross_income', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Haberes (Bruto)')), + ('afp_deduction', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Cotización Previsional AFP')), + ('health_deduction', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Cotización Legal Salud (7% o Isapre)')), + ('afc_deduction', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro de Cesantía Trabajador (AFC 0.6%)')), + ('second_category_tax', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Impuesto Único 2da Categoría SII')), + ('other_deductions', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Otros Descuentos / Anticipos')), + ('total_deductions', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Descuentos')), + ('net_salary', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Sueldo Líquido a Pagar')), + ('employer_sis', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro Invalidez y Sobrevivencia SIS (1.49%)')), + ('employer_mutual', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Aporte Mutualidad / ACHS (0.93%)')), + ('employer_afc', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro Cesantía Empleador AFC (2.4%)')), + ('total_company_cost', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Costo Total Empresa')), + ('status', models.CharField(choices=[('BORRADOR', 'Borrador'), ('EMITIDA', 'Emitida (Pendiente de Firma)'), ('FIRMADA', 'Firmada Digitalmente'), ('PAGADA', 'Pagada / Transferida'), ('ANULADA', 'Anulada')], default='BORRADOR', max_length=30, verbose_name='Estado de Liquidación')), + ('digital_signature_hash', models.CharField(blank=True, max_length=128, verbose_name='Hash de Firma Digital (SHA-256)')), + ('signed_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha y Hora de Firma')), + ('notes', models.TextField(blank=True, verbose_name='Observaciones')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='salary_slips', to='payroll.employeeprofile', verbose_name='Colaborador')), + ], + options={ + 'verbose_name': 'Liquidación de Sueldo', + 'verbose_name_plural': 'Liquidaciones de Sueldo (Buk)', + 'ordering': ['-year', '-month', 'employee__last_name'], + 'unique_together': {('employee', 'month', 'year')}, + }, + ), + ] diff --git a/payroll/migrations/__init__.py b/payroll/migrations/__init__.py new file mode 100644 index 0000000..cea7157 --- /dev/null +++ b/payroll/migrations/__init__.py @@ -0,0 +1 @@ +# Payroll migrations package diff --git a/payroll/models.py b/payroll/models.py new file mode 100644 index 0000000..1a85ddd --- /dev/null +++ b/payroll/models.py @@ -0,0 +1,213 @@ +from django.db import models +from django.contrib.auth.models import User +from decimal import Decimal +import uuid + +class ContractTypeEnum(models.TextChoices): + INDEFINIDO = 'INDEFINIDO', 'Contrato Indefinido' + PLAZO_FIJO = 'PLAZO_FIJO', 'Contrato a Plazo Fijo' + HONORARIOS = 'HONORARIOS', 'Prestación de Servicios (Honorarios)' + PRACTICA = 'PRACTICA', 'Práctica Profesional' + +class WorkScheduleEnum(models.TextChoices): + LEY_21561_40H = '40H_LEY_21561', '40 Horas Semanales (Ley 21.561)' + REGULAR_44H = '44H', '44 Horas Semanales' + REGULAR_45H = '45H', '45 Horas Semanales (Antiguo)' + PART_TIME_30H = '30H_PART_TIME', 'Part-time 30 Horas' + PART_TIME_20H = '20H_PART_TIME', 'Part-time 20 Horas' + ART_22 = 'ART_22_INC_2', 'Sin Fiscalización Superior (Art. 22 Inc. 2)' + +class AFPEnum(models.TextChoices): + HABITAT = 'HABITAT', 'AFP Habitat (11.27%)' + CUPRUM = 'CUPRUM', 'AFP Cuprum (11.44%)' + MODELO = 'MODELO', 'AFP Modelo (10.58%)' + PLANVITAL = 'PLANVITAL', 'AFP PlanVital (11.16%)' + PROVIDA = 'PROVIDA', 'AFP ProVida (11.45%)' + CAPITAL = 'CAPITAL', 'AFP Capital (11.44%)' + UNO = 'UNO', 'AFP Uno (10.49%)' + +class HealthSystemEnum(models.TextChoices): + FONASA = 'FONASA', 'FONASA (7.00%)' + ISAPRE = 'ISAPRE', 'ISAPRE (Pactado UF / %)' + +class BankAccountTypeEnum(models.TextChoices): + CORRIENTE = 'CORRIENTE', 'Cuenta Corriente' + VISTA_RUT = 'VISTA_RUT', 'Cuenta Vista / Cuenta RUT' + AHORRO = 'AHORRO', 'Cuenta de Ahorro' + +class SalarySlipStatusEnum(models.TextChoices): + BORRADOR = 'BORRADOR', 'Borrador' + EMITIDA = 'EMITIDA', 'Emitida (Pendiente de Firma)' + FIRMADA = 'FIRMADA', 'Firmada Digitalmente' + PAGADA = 'PAGADA', 'Pagada / Transferida' + ANULADA = 'ANULADA', 'Anulada' + + +class EmployeeProfile(models.Model): + """ + Ficha de Empleado / Colaborador conforme a la legislación laboral chilena (Ley 21.561 40h). + """ + user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='employee_profile', verbose_name="Usuario del Sistema") + rut = models.CharField(max_length=20, unique=True, verbose_name="RUT Trabajador") + first_name = models.CharField(max_length=100, verbose_name="Nombres") + last_name = models.CharField(max_length=100, verbose_name="Apellidos") + email = models.EmailField(verbose_name="Correo Electrónico") + phone = models.CharField(max_length=30, blank=True, verbose_name="Teléfono Móvil") + address = models.CharField(max_length=255, blank=True, verbose_name="Dirección Particular") + + position = models.CharField(max_length=150, verbose_name="Cargo / Especialidad I+D+i", default="Ingeniero de Desarrollo") + department = models.CharField(max_length=100, verbose_name="Departamento / Área", default="I+D+i y Operaciones") + + contract_type = models.CharField(max_length=50, choices=ContractTypeEnum.choices, default=ContractTypeEnum.INDEFINIDO, verbose_name="Tipo de Contrato") + work_schedule = models.CharField(max_length=50, choices=WorkScheduleEnum.choices, default=WorkScheduleEnum.LEY_21561_40H, verbose_name="Jornada Laboral") + hire_date = models.DateField(verbose_name="Fecha de Contratación") + + # Previsional + afp = models.CharField(max_length=50, choices=AFPEnum.choices, default=AFPEnum.MODELO, verbose_name="AFP Previsional") + afp_rate = models.DecimalField(max_digits=5, decimal_places=2, default=10.58, verbose_name="Tasa Cotización AFP (%)") + + health_system = models.CharField(max_length=50, choices=HealthSystemEnum.choices, default=HealthSystemEnum.FONASA, verbose_name="Sistema de Salud") + health_institution = models.CharField(max_length=100, blank=True, default="FONASA", verbose_name="Institución Salud (FONASA / Isapre)") + health_plan_uf = models.DecimalField(max_digits=6, decimal_places=4, default=0, verbose_name="Plan Salud Pactado (UF - solo Isapre)") + health_rate = models.DecimalField(max_digits=5, decimal_places=2, default=7.00, verbose_name="Tasa Legal Salud (%)") + + has_afc = models.BooleanField(default=True, verbose_name="Afecto a Seguro de Cesantía (AFC)") + + # Remuneración Pactada + base_salary_clp = models.DecimalField(max_digits=12, decimal_places=2, default=1500000, verbose_name="Sueldo Base Mensual (CLP)") + colacion_clp = models.DecimalField(max_digits=10, decimal_places=2, default=65000, verbose_name="Asignación Colación (No Imponible)") + movilizacion_clp = models.DecimalField(max_digits=10, decimal_places=2, default=65000, verbose_name="Asignación Movilización (No Imponible)") + + # Datos Bancarios para Pago de Nómina + bank_name = models.CharField(max_length=100, default="Banco Santander-Chile", verbose_name="Banco Receptor") + account_type = models.CharField(max_length=50, choices=BankAccountTypeEnum.choices, default=BankAccountTypeEnum.CORRIENTE, verbose_name="Tipo de Cuenta") + account_number = models.CharField(max_length=50, default="00000000", verbose_name="Número de Cuenta Bancaria") + + is_active = models.BooleanField(default=True, verbose_name="Trabajador Activo") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.rut} - {self.first_name} {self.last_name} ({self.position})" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + class Meta: + verbose_name = "Perfil de Colaborador" + verbose_name_plural = "Nómina de Colaboradores" + ordering = ['last_name', 'first_name'] + + +class SalarySlip(models.Model): + """ + Liquidación de Sueldo Digital (Estilo Buk). + Calcula desglose analítico de Haberes Imponibles/No Imponibles, Descuentos Legales, + Impuesto Único de Segunda Categoría SII y Aportes Patronales (SIS, Mutual, AFC). + """ + slip_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + employee = models.ForeignKey(EmployeeProfile, on_delete=models.CASCADE, related_name='salary_slips', verbose_name="Colaborador") + + month = models.IntegerField(verbose_name="Mes (1-12)") + year = models.IntegerField(verbose_name="Año (ej. 2026)") + issue_date = models.DateField(verbose_name="Fecha de Emisión") + days_worked = models.IntegerField(default=30, verbose_name="Días Trabajados") + + # 1. Haberes Imponibles + base_salary = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Sueldo Base") + overtime_hours_50 = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name="Horas Extras (50%)") + overtime_amount_50 = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Monto Horas Extras 50%") + legal_gratification = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Gratificación Legal (Art. 50)") + rd_bonus = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Bonos de Rendimiento I+D+i") + other_taxable = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Otros Haberes Imponibles") + total_taxable_income = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Haberes Imponibles") + + # 2. Haberes No Imponibles + allowance_lunch = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Asignación Colación") + allowance_commute = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Asignación Movilización") + expense_reimbursements = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Reembolsos de Gastos en Terreno") + total_non_taxable_income = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Total Haberes No Imponibles") + + total_gross_income = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Haberes (Bruto)") + + # 3. Descuentos Previsionales e Impositivos + afp_deduction = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Cotización Previsional AFP") + health_deduction = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Cotización Legal Salud (7% o Isapre)") + afc_deduction = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro de Cesantía Trabajador (AFC 0.6%)") + second_category_tax = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Impuesto Único 2da Categoría SII") + other_deductions = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Otros Descuentos / Anticipos") + total_deductions = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Descuentos") + + # 4. Líquido a Pagar + net_salary = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Sueldo Líquido a Pagar") + + # 5. Cargas Sociales del Empleador (Costo Empresa) + employer_sis = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro Invalidez y Sobrevivencia SIS (1.49%)") + employer_mutual = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Aporte Mutualidad / ACHS (0.93%)") + employer_afc = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro Cesantía Empleador AFC (2.4%)") + total_company_cost = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Costo Total Empresa") + + # 6. Workflow y Firma Digital + status = models.CharField(max_length=30, choices=SalarySlipStatusEnum.choices, default=SalarySlipStatusEnum.BORRADOR, verbose_name="Estado de Liquidación") + digital_signature_hash = models.CharField(max_length=128, blank=True, verbose_name="Hash de Firma Digital (SHA-256)") + signed_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha y Hora de Firma") + notes = models.TextField(blank=True, verbose_name="Observaciones") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Liquidación {self.month:02d}/{self.year} - {self.employee.full_name} (${self.net_salary:,.0f} CLP)" + + @property + def period_display(self): + months = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"] + m_name = months[self.month - 1] if 1 <= self.month <= 12 else str(self.month) + return f"{m_name} {self.year}" + + class Meta: + verbose_name = "Liquidación de Sueldo" + verbose_name_plural = "Liquidaciones de Sueldo (Buk)" + unique_together = ('employee', 'month', 'year') + ordering = ['-year', '-month', 'employee__last_name'] + + +class PayrollCentralizationEntry(models.Model): + """ + Asiento Contable de Centralización de Remuneraciones. + Imputa a Cuenta 5101: Remuneraciones y Cargas Sociales I+D+i. + Valida cuadratura Débitos = Créditos. + """ + month = models.IntegerField(verbose_name="Mes") + year = models.IntegerField(verbose_name="Año") + entry_date = models.DateField(verbose_name="Fecha del Asiento") + + glosa = models.CharField(max_length=255, verbose_name="Glosa Contable", default="Centralización de Remuneraciones y Leyes Sociales") + account_code = models.CharField(max_length=50, default="5101", verbose_name="Cuenta Contable Débito (5101)") + account_name = models.CharField(max_length=150, default="Gastos en Personal y Cargas Sociales I+D+i", verbose_name="Nombre Cuenta Débito") + + # Componentes del Asiento + total_taxable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haberes Imponibles") + total_non_taxable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haberes No Imponibles") + total_employer_charges = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Cargas Sociales Empleador (SIS/ACHS/AFC)") + + total_net_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Sueldos Líquidos por Pagar (Banco)") + total_previred_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Cotizaciones por Pagar (Previred)") + total_sii_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Retención Impuesto Único SII") + + total_debit = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Debe (CLP)") + total_credit = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haber (CLP)") + is_balanced = models.BooleanField(default=True, verbose_name="Asiento Cuadrado (Debe == Haber)") + + status = models.CharField(max_length=30, default="CONTABILIZADO", verbose_name="Estado Asiento") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Asiento Centralización {self.month:02d}/{self.year} - {self.account_code}: {self.total_debit:,.0f} CLP" + + class Meta: + verbose_name = "Asiento de Centralización de Remuneraciones" + verbose_name_plural = "Asientos de Centralización (Cuenta 5101)" + unique_together = ('month', 'year') + ordering = ['-year', '-month'] diff --git a/payroll/services/__init__.py b/payroll/services/__init__.py new file mode 100644 index 0000000..c84853f --- /dev/null +++ b/payroll/services/__init__.py @@ -0,0 +1,3 @@ +from .engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService + +__all__ = ['PayrollEngine', 'BankExportService', 'PreviredExportService', 'LREExportService'] diff --git a/payroll/services/engine.py b/payroll/services/engine.py new file mode 100644 index 0000000..e417f12 --- /dev/null +++ b/payroll/services/engine.py @@ -0,0 +1,352 @@ +""" +Payroll Engine and Legal Exporters for Chilean Labor Regulation (Ley 21.561 40h, Previred, DT LRE, Banking). +""" + +from decimal import Decimal, ROUND_HALF_UP +import hashlib +from datetime import datetime, date +from typing import Dict, List, Any +import io +import csv + +class PayrollEngine: + # 2026 Benchmark Economic Indicators (Chile) + IMM_2026 = Decimal('500000') # Ingreso Mínimo Mensual + UTM_2026 = Decimal('67000') # Unidad Tributaria Mensual + UF_2026 = Decimal('38500') # Unidad de Fomento + + # Previsional Caps in UF + AFP_HEALTH_CAP_UF = Decimal('84.3') # Tope Imponible AFP y Salud (84.3 UF) + AFC_CAP_UF = Decimal('126.6') # Tope Imponible Seguro de Cesantía (126.6 UF) + + # Employer Rates + SIS_RATE = Decimal('0.0149') # Seguro de Invalidez y Sobrevivencia (1.49%) + MUTUAL_RATE = Decimal('0.0093') # Mutual de Seguridad / ACHS (0.93% base) + AFC_EMPLOYER_INDEF = Decimal('0.024') # AFC Empleador Contrato Indefinido (2.4%) + AFC_EMPLOYER_FIJO = Decimal('0.030') # AFC Empleador Contrato Plazo Fijo (3.0%) + AFC_WORKER_RATE = Decimal('0.006') # AFC Trabajador Contrato Indefinido (0.6%) + + @classmethod + def calculate_overtime(cls, base_salary: Decimal, weekly_hours: int, overtime_hours: Decimal) -> Decimal: + """ + Calculates 50% overtime pay based on weekly hours (Ley 21.561 40h vs 44h vs 45h). + Formula: (BaseSalary / WeeklyHours) * (7 / 30) * 1.5 * OvertimeHours + For 40 hours: Factor = (1/40) * (7/30) * 1.5 = 0.00875 + """ + if overtime_hours <= 0 or base_salary <= 0: + return Decimal('0') + + hours = Decimal(str(weekly_hours if weekly_hours > 0 else 40)) + factor = (Decimal('1') / hours) * (Decimal('7') / Decimal('30')) * Decimal('1.5') + amount = base_salary * factor * overtime_hours + return amount.quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + @classmethod + def calculate_legal_gratification(cls, base_salary: Decimal, overtime_amount: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0')) -> Decimal: + """ + Calculates Legal Gratification under Art. 50 Código del Trabajo: + 25% of taxable earnings capped at (4.75 * IMM) / 12 per month. + """ + monthly_cap = ((Decimal('4.75') * cls.IMM_2026) / Decimal('12')).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + earnings = (base_salary + overtime_amount + rd_bonus) * Decimal('0.25') + return min(earnings.quantize(Decimal('1'), rounding=ROUND_HALF_UP), monthly_cap) + + @classmethod + def calculate_second_category_tax(cls, taxable_tax_base: Decimal) -> Decimal: + """ + Calculates Chilean Second Category Income Tax (Impuesto Único de Segunda Categoría SII). + Taxable Tax Base = Total Taxable Income - Previsional Deductions (AFP + 7% Health + AFC). + """ + if taxable_tax_base <= Decimal('0'): + return Decimal('0') + + utm = cls.UTM_2026 + base_in_utm = taxable_tax_base / utm + + # Official SII Brackets + # Tramo 1: 0 - 13.5 UTM -> Exento + if base_in_utm <= Decimal('13.5'): + return Decimal('0') + # Tramo 2: 13.5 - 30 UTM -> 4% (rebaja 0.54 UTM) + elif base_in_utm <= Decimal('30'): + tax = (taxable_tax_base * Decimal('0.04')) - (Decimal('0.54') * utm) + # Tramo 3: 30 - 50 UTM -> 8% (rebaja 1.74 UTM) + elif base_in_utm <= Decimal('50'): + tax = (taxable_tax_base * Decimal('0.08')) - (Decimal('1.74') * utm) + # Tramo 4: 50 - 70 UTM -> 13.5% (rebaja 4.49 UTM) + elif base_in_utm <= Decimal('70'): + tax = (taxable_tax_base * Decimal('0.135')) - (Decimal('4.49') * utm) + # Tramo 5: 70 - 90 UTM -> 23% (rebaja 11.14 UTM) + elif base_in_utm <= Decimal('90'): + tax = (taxable_tax_base * Decimal('0.23')) - (Decimal('11.14') * utm) + # Tramo 6: 90 - 120 UTM -> 30.4% (rebaja 17.80 UTM) + elif base_in_utm <= Decimal('120'): + tax = (taxable_tax_base * Decimal('0.304')) - (Decimal('17.80') * utm) + # Tramo 7: 120 - 310 UTM -> 35% (rebaja 23.32 UTM) + elif base_in_utm <= Decimal('310'): + tax = (taxable_tax_base * Decimal('0.35')) - (Decimal('23.32') * utm) + # Tramo 8: > 310 UTM -> 40% (rebaja 38.82 UTM) + else: + tax = (taxable_tax_base * Decimal('0.40')) - (Decimal('38.82') * utm) + + return max(Decimal('0'), tax.quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + @classmethod + def compute_salary_slip(cls, employee, month: int, year: int, days_worked: int = 30, + overtime_hours: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0'), + other_taxable: Decimal = Decimal('0'), other_deductions: Decimal = Decimal('0')) -> Dict[str, Any]: + """ + Executes complete salary slip calculation according to Chilean Labor and Tax code. + """ + base_salary_pactado = Decimal(str(employee.base_salary_clp)) + days = Decimal(str(days_worked)) + # Base salary proportional to days worked (standard 30 days) + base_salary_period = (base_salary_pactado * (days / Decimal('30'))).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + # Weekly hours determination + schedule_hours = 40 + if employee.work_schedule == '44H': + schedule_hours = 44 + elif employee.work_schedule == '45H': + schedule_hours = 45 + elif employee.work_schedule == '30H_PART_TIME': + schedule_hours = 30 + elif employee.work_schedule == '20H_PART_TIME': + schedule_hours = 20 + + # Overtime + overtime_amount = cls.calculate_overtime(base_salary_period, schedule_hours, overtime_hours) + + # Legal Gratification Art. 50 + legal_grat = cls.calculate_legal_gratification(base_salary_period, overtime_amount, rd_bonus) + + # Total Taxable + total_taxable = base_salary_period + overtime_amount + legal_grat + rd_bonus + other_taxable + + # Non-taxable items + allowance_lunch = Decimal(str(employee.colacion_clp or 0)) + allowance_commute = Decimal(str(employee.movilizacion_clp or 0)) + total_non_taxable = allowance_lunch + allowance_commute + + total_gross = total_taxable + total_non_taxable + + # Previsional Caps Application + afp_health_cap_clp = (cls.AFP_HEALTH_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + afc_cap_clp = (cls.AFC_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + taxable_for_afp_health = min(total_taxable, afp_health_cap_clp) + taxable_for_afc = min(total_taxable, afc_cap_clp) + + # AFP Deduction + afp_rate = Decimal(str(employee.afp_rate or 10.58)) / Decimal('100') + afp_deduction = (taxable_for_afp_health * afp_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + # Health Deduction + if employee.health_system == 'ISAPRE' and employee.health_plan_uf > 0: + pactado_isapre_clp = (Decimal(str(employee.health_plan_uf)) * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + legal_7_pct = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + health_deduction = max(pactado_isapre_clp, legal_7_pct) + else: + # Fonasa or 7% + health_deduction = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + # AFC Worker Deduction (0.6% if indefinite and has AFC) + if employee.has_afc and employee.contract_type == 'INDEFINIDO': + afc_deduction = (taxable_for_afc * cls.AFC_WORKER_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + else: + afc_deduction = Decimal('0') + + # Tax Base for Second Category Tax + tax_base = total_taxable - afp_deduction - min(health_deduction, (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'))) - afc_deduction + second_cat_tax = cls.calculate_second_category_tax(tax_base) + + # Total Deductions + total_deductions = afp_deduction + health_deduction + afc_deduction + second_cat_tax + other_deductions + + # Net Salary + net_salary = total_gross - total_deductions + + # Employer Charges (Costo Empresa) + employer_sis = (taxable_for_afp_health * cls.SIS_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + employer_mutual = (taxable_for_afp_health * cls.MUTUAL_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + + if employee.has_afc: + if employee.contract_type == 'INDEFINIDO': + employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_INDEF).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + else: + employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_FIJO).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + else: + employer_afc = Decimal('0') + + total_employer_charges = employer_sis + employer_mutual + employer_afc + total_company_cost = total_gross + total_employer_charges + + return { + 'month': month, + 'year': year, + 'days_worked': days_worked, + 'base_salary': base_salary_period, + 'overtime_hours_50': overtime_hours, + 'overtime_amount_50': overtime_amount, + 'legal_gratification': legal_grat, + 'rd_bonus': rd_bonus, + 'other_taxable': other_taxable, + 'total_taxable_income': total_taxable, + 'allowance_lunch': allowance_lunch, + 'allowance_commute': allowance_commute, + 'total_non_taxable_income': total_non_taxable, + 'total_gross_income': total_gross, + 'afp_deduction': afp_deduction, + 'health_deduction': health_deduction, + 'afc_deduction': afc_deduction, + 'second_category_tax': second_cat_tax, + 'other_deductions': other_deductions, + 'total_deductions': total_deductions, + 'net_salary': net_salary, + 'employer_sis': employer_sis, + 'employer_mutual': employer_mutual, + 'employer_afc': employer_afc, + 'total_employer_charges': total_employer_charges, + 'total_company_cost': total_company_cost, + } + + @staticmethod + def generate_signature_hash(slip_id: int, rut: str, net_amount: Decimal, timestamp: datetime) -> str: + """Generates cryptographic SHA-256 digital signature token for Buk-style slip validation.""" + payload = f"EFINTEL-BUK|SLIP:{slip_id}|RUT:{rut}|NET:{net_amount}|TS:{timestamp.isoformat()}" + return hashlib.sha256(payload.encode('utf-8')).hexdigest() + + +class BankExportService: + """ + Exports payroll disbursements into standard Chilean banking formats (TXT/CSV). + """ + @staticmethod + def export_santander_format(slips: List) -> str: + """Generates Banco Santander Nómina CSV/TXT.""" + output = io.StringIO() + writer = csv.writer(output, delimiter=';') + writer.writerow(['RUT_BENEFICIARIO', 'NOMBRE_BENEFICIARIO', 'BANCO_DESTINO', 'TIPO_CUENTA', 'NUMERO_CUENTA', 'MONTO_CLP', 'GLOSA']) + for slip in slips: + emp = slip.employee + writer.writerow([ + emp.rut, + emp.full_name, + emp.bank_name, + emp.get_account_type_display(), + emp.account_number, + int(slip.net_salary), + f"Sueldo {slip.month:02d}/{slip.year} EFINTEL" + ]) + return output.getvalue() + + @staticmethod + def export_banco_chile_txt(slips: List) -> str: + """Generates Banco de Chile flat file format.""" + lines = [] + for slip in slips: + emp = slip.employee + clean_rut = emp.rut.replace('.', '').replace('-', '').zfill(10) + clean_name = emp.full_name[:30].ljust(30) + account_num = emp.account_number.replace('-', '')[:18].zfill(18) + amount_str = str(int(slip.net_salary)).zfill(11) + line = f"01{clean_rut}{clean_name}{account_num}{amount_str}001" + lines.append(line) + return "\n".join(lines) + + +class PreviredExportService: + """ + Generates official Previred 105-fields previsional payment file. + """ + @staticmethod + def generate_previred_105(slips: List, company_rut: str = "77.123.456-7") -> str: + """ + Builds positional/delimited 105-column dataset for bulk previsional declaration at Previred. + """ + output = io.StringIO() + for slip in slips: + emp = slip.employee + rut_parts = emp.rut.replace('.', '').split('-') + rut_num = rut_parts[0].zfill(9) if len(rut_parts) > 0 else "000000000" + rut_dv = rut_parts[1].upper() if len(rut_parts) > 1 else "0" + + # Fields 1 to 105 representation + row = [ + rut_num, rut_dv, emp.last_name[:30], emp.first_name[:30], + "M" if "M" in emp.rut else "F", # Gen + "056", # Pais + "0", # Tipo trab + "1", # Tipo pago + f"{slip.year}{slip.month:02d}", # Periodo + f"{slip.year}{slip.month:02d}01", # Fecha desde + f"{slip.year}{slip.month:02d}30", # Fecha hasta + str(slip.days_worked), # Dias trab + "0", # Tipo linea + str(int(slip.total_taxable_income)), # Renta imp + str(int(slip.total_taxable_income)), # Renta imp AFP + str(int(slip.afp_deduction)), # Cotiz obligatoria + str(int(slip.employer_sis)), # SIS + emp.afp, # Codigo AFP + "0", # Ahorro voluntario + str(int(slip.total_taxable_income)), # Renta imp Salud + str(int(slip.health_deduction)), # Cotiz Salud 7% + emp.health_system, # Fonasa / Isapre + str(int(slip.employer_mutual)), # Mutualidad ACHS + str(int(slip.total_taxable_income)), # Renta imp AFC + str(int(slip.afc_deduction)), # AFC Trabajador + str(int(slip.employer_afc)), # AFC Empleador + str(int(slip.second_category_tax)), # Impuesto Unico + ] + # Fill remaining columns to complete 105 Previred fields standard + while len(row) < 105: + row.append("0") + + output.write(";".join(row) + "\n") + return output.getvalue() + + +class LREExportService: + """ + Exports Libro de Remuneraciones Electrónico (LRE) conforming to Dirección del Trabajo (DT). + """ + @staticmethod + def generate_dt_lre_csv(slips: List) -> str: + output = io.StringIO() + writer = csv.writer(output, delimiter=';') + + # DT LRE Header Standard + header = [ + 'RUT_TRABAJADOR', 'DV_TRABAJADOR', 'APELLIDO_PATERNO', 'APELLIDO_MATERNO', 'NOMBRES', + 'SUELDO_BASE_1101', 'SOBRESUELDO_HORAS_EXTRAS_1102', 'GRATIFICACION_LEGAL_1104', + 'BONOS_INCENTIVOS_1106', 'TOTAL_HABERES_IMPONIBLES_1100', 'ASIGNACION_COLACION_2101', + 'ASIGNACION_MOVILIZACION_2102', 'TOTAL_HABERES_NO_IMPONIBLES_2100', 'TOTAL_HABERES_BRUTO', + 'COTIZACION_OBLIGATORIA_AFP_3101', 'SEGURO_INVALIDEZ_SOBREVIVENCIA_SIS', 'COTIZACION_SALUD_3102', + 'COTIZACION_SEGURO_CESANTIA_TRABAJADOR_3103', 'IMPUESTO_UNICO_SEGUNDA_CATEGORIA_3104', + 'TOTAL_DESCUENTOS_PREVISIONALES_3100', 'TOTAL_DESCUENTOS', 'SUELDO_LIQUIDO_ALCANCE_LIQUIDO', + 'APORTE_PATRONAL_MUTUALIDAD', 'APORTE_PATRONAL_AFC', 'COSTO_TOTAL_EMPRESA' + ] + writer.writerow(header) + + for s in slips: + emp = s.employee + rut_parts = emp.rut.replace('.', '').split('-') + rut_n = rut_parts[0] if len(rut_parts) > 0 else "" + rut_d = rut_parts[1] if len(rut_parts) > 1 else "" + + name_parts = emp.last_name.split(' ') + pat = name_parts[0] if len(name_parts) > 0 else emp.last_name + mat = name_parts[1] if len(name_parts) > 1 else "" + + writer.writerow([ + rut_n, rut_d, pat, mat, emp.first_name, + int(s.base_salary), int(s.overtime_amount_50), int(s.legal_gratification), + int(s.rd_bonus), int(s.total_taxable_income), int(s.allowance_lunch), + int(s.allowance_commute), int(s.total_non_taxable_income), int(s.total_gross_income), + int(s.afp_deduction), int(s.employer_sis), int(s.health_deduction), + int(s.afc_deduction), int(s.second_category_tax), + int(s.afp_deduction + s.health_deduction + s.afc_deduction), + int(s.total_deductions), int(s.net_salary), + int(s.employer_mutual), int(s.employer_afc), int(s.total_company_cost) + ]) + return output.getvalue() diff --git a/payroll/urls.py b/payroll/urls.py new file mode 100644 index 0000000..19602e0 --- /dev/null +++ b/payroll/urls.py @@ -0,0 +1,22 @@ +from django.urls import path +from . import views + +app_name = 'payroll' + +urlpatterns = [ + path('', views.employee_list, name='payroll_index'), + path('colaboradores/', views.employee_list, name='employee_list'), + path('colaboradores/nuevo/', views.employee_create, name='employee_create'), + path('colaboradores//editar/', views.employee_edit, name='employee_edit'), + + path('liquidaciones/', views.slip_list, name='slip_list'), + path('liquidaciones/generar/', views.slip_generate, name='slip_generate'), + path('liquidaciones//', views.slip_detail, name='slip_detail'), + path('liquidaciones//firmar/', views.slip_sign, name='slip_sign'), + + path('centralizacion/', views.centralization_view, name='centralization'), + + path('exportar/previred/', views.export_previred, name='export_previred'), + path('exportar/lre/', views.export_lre, name='export_lre'), + path('exportar/banco/', views.export_bank_txt, name='export_bank_txt'), +] diff --git a/payroll/views.py b/payroll/views.py new file mode 100644 index 0000000..25a4090 --- /dev/null +++ b/payroll/views.py @@ -0,0 +1,247 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.http import HttpResponse, JsonResponse +from django.utils import timezone +from django.db.models import Sum, Count +from decimal import Decimal +from datetime import date, datetime + +from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry, SalarySlipStatusEnum +from .forms import EmployeeProfileForm, SalarySlipGenerateForm +from .services.engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService + +@login_required +def employee_list(request): + employees = EmployeeProfile.objects.all().order_by('last_name', 'first_name') + active_count = employees.filter(is_active=True).count() + total_base_clp = employees.filter(is_active=True).aggregate(Sum('base_salary_clp'))['base_salary_clp__sum'] or 0 + + return render(request, 'payroll/employee_list.html', { + 'employees': employees, + 'active_count': active_count, + 'total_base_clp': total_base_clp, + }) + +@login_required +def employee_create(request): + if request.method == 'POST': + form = EmployeeProfileForm(request.POST) + if form.is_valid(): + emp = form.save() + messages.success(request, f"Colaborador {emp.full_name} registrado exitosamente.") + return redirect('payroll:employee_list') + else: + form = EmployeeProfileForm() + return render(request, 'payroll/employee_form.html', {'form': form, 'title': 'Registrar Nuevo Colaborador'}) + +@login_required +def employee_edit(request, pk): + emp = get_object_or_404(EmployeeProfile, pk=pk) + if request.method == 'POST': + form = EmployeeProfileForm(request.POST, instance=emp) + if form.is_valid(): + emp = form.save() + messages.success(request, f"Ficha de {emp.full_name} actualizada.") + return redirect('payroll:employee_list') + else: + form = EmployeeProfileForm(instance=emp) + return render(request, 'payroll/employee_form.html', {'form': form, 'title': f"Editar Colaborador: {emp.full_name}"}) + +@login_required +def slip_list(request): + month = request.GET.get('month') + year = request.GET.get('year', 2026) + + slips = SalarySlip.objects.select_related('employee').all() + if month: + slips = slips.filter(month=int(month)) + if year: + slips = slips.filter(year=int(year)) + + totals = slips.aggregate( + total_liquid=Sum('net_salary'), + total_gross=Sum('total_gross_income'), + total_company=Sum('total_company_cost') + ) + + return render(request, 'payroll/slip_list.html', { + 'slips': slips, + 'selected_month': month, + 'selected_year': int(year), + 'total_liquid': totals['total_liquid'] or 0, + 'total_gross': totals['total_gross'] or 0, + 'total_company': totals['total_company'] or 0, + }) + +@login_required +def slip_generate(request): + if request.method == 'POST': + form = SalarySlipGenerateForm(request.POST) + if form.is_valid(): + month = int(form.cleaned_data['month']) + year = int(form.cleaned_data['year']) + specific_employee = form.cleaned_data.get('employee') + overtime_hours = form.cleaned_data.get('overtime_hours') or Decimal('0') + rd_bonus = form.cleaned_data.get('rd_bonus') or Decimal('0') + other_deductions = form.cleaned_data.get('other_deductions') or Decimal('0') + + employees_to_calc = [specific_employee] if specific_employee else list(EmployeeProfile.objects.filter(is_active=True)) + + created_count = 0 + for emp in employees_to_calc: + # Calculate + calc = PayrollEngine.compute_salary_slip( + employee=emp, + month=month, + year=year, + days_worked=30, + overtime_hours=overtime_hours if specific_employee else Decimal('0'), + rd_bonus=rd_bonus if specific_employee else Decimal('0'), + other_deductions=other_deductions if specific_employee else Decimal('0') + ) + + slip, created = SalarySlip.objects.update_or_create( + employee=emp, + month=month, + year=year, + defaults={ + 'issue_date': date(year, month, 28), + 'days_worked': calc['days_worked'], + 'base_salary': calc['base_salary'], + 'overtime_hours_50': calc['overtime_hours_50'], + 'overtime_amount_50': calc['overtime_amount_50'], + 'legal_gratification': calc['legal_gratification'], + 'rd_bonus': calc['rd_bonus'], + 'other_taxable': calc['other_taxable'], + 'total_taxable_income': calc['total_taxable_income'], + 'allowance_lunch': calc['allowance_lunch'], + 'allowance_commute': calc['allowance_commute'], + 'total_non_taxable_income': calc['total_non_taxable_income'], + 'total_gross_income': calc['total_gross_income'], + 'afp_deduction': calc['afp_deduction'], + 'health_deduction': calc['health_deduction'], + 'afc_deduction': calc['afc_deduction'], + 'second_category_tax': calc['second_category_tax'], + 'other_deductions': calc['other_deductions'], + 'total_deductions': calc['total_deductions'], + 'net_salary': calc['net_salary'], + 'employer_sis': calc['employer_sis'], + 'employer_mutual': calc['employer_mutual'], + 'employer_afc': calc['employer_afc'], + 'total_company_cost': calc['total_company_cost'], + 'status': SalarySlipStatusEnum.EMITIDA + } + ) + created_count += 1 + + # Auto-update centralization entry for this month/year + month_slips = SalarySlip.objects.filter(month=month, year=year) + m_totals = month_slips.aggregate( + sum_taxable=Sum('total_taxable_income'), + sum_non_taxable=Sum('total_non_taxable_income'), + sum_employer=Sum('employer_sis') + Sum('employer_mutual') + Sum('employer_afc'), + sum_net=Sum('net_salary'), + sum_previred=Sum('afp_deduction') + Sum('health_deduction') + Sum('afc_deduction') + Sum('employer_sis') + Sum('employer_mutual') + Sum('employer_afc'), + sum_sii=Sum('second_category_tax'), + sum_cost=Sum('total_company_cost') + ) + + debit_total = (m_totals['sum_cost'] or Decimal('0')) + credit_total = (m_totals['sum_net'] or Decimal('0')) + (m_totals['sum_previred'] or Decimal('0')) + (m_totals['sum_sii'] or Decimal('0')) + + PayrollCentralizationEntry.objects.update_or_create( + month=month, + year=year, + defaults={ + 'entry_date': date(year, month, 28), + 'glosa': f"Centralización Sueldos y Leyes Sociales {month:02d}/{year} EFINTEL I+D+i", + 'account_code': '5101', + 'account_name': 'Remuneraciones y Cargas Sociales I+D+i', + 'total_taxable': m_totals['sum_taxable'] or 0, + 'total_non_taxable': m_totals['sum_non_taxable'] or 0, + 'total_employer_charges': m_totals['sum_employer'] or 0, + 'total_net_payable': m_totals['sum_net'] or 0, + 'total_previred_payable': m_totals['sum_previred'] or 0, + 'total_sii_payable': m_totals['sum_sii'] or 0, + 'total_debit': debit_total, + 'total_credit': credit_total, + 'is_balanced': abs(debit_total - credit_total) < Decimal('1.00'), + 'status': 'CONTABILIZADO' + } + ) + + messages.success(request, f"Se generaron/actualizaron con éxito {created_count} liquidaciones para el periodo {month:02d}/{year}.") + return redirect('payroll:slip_list') + else: + form = SalarySlipGenerateForm() + return render(request, 'payroll/slip_generate.html', {'form': form}) + +@login_required +def slip_detail(request, pk): + slip = get_object_or_404(SalarySlip.objects.select_related('employee'), pk=pk) + return render(request, 'payroll/slip_detail.html', {'slip': slip}) + +@login_required +def slip_sign(request, pk): + slip = get_object_or_404(SalarySlip, pk=pk) + if request.method == 'POST': + now = timezone.now() + sig_hash = PayrollEngine.generate_signature_hash(slip.id, slip.employee.rut, slip.net_salary, now) + slip.digital_signature_hash = sig_hash + slip.signed_at = now + slip.status = SalarySlipStatusEnum.FIRMADA + slip.save() + messages.success(request, f"Liquidación firmada digitalmente con éxito. Hash: {sig_hash[:16]}...") + return redirect('payroll:slip_detail', pk=pk) + +@login_required +def centralization_view(request): + entries = PayrollCentralizationEntry.objects.all().order_by('-year', '-month') + return render(request, 'payroll/centralization.html', {'entries': entries}) + +# Export Views +@login_required +def export_previred(request): + month = request.GET.get('month', date.today().month) + year = request.GET.get('year', date.today().year) + slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee') + + if not slips.exists(): + messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.") + return redirect('payroll:slip_list') + + content = PreviredExportService.generate_previred_105(list(slips)) + response = HttpResponse(content, content_type='text/plain; charset=iso-8859-1') + response['Content-Disposition'] = f'attachment; filename="PREVIRED_105_{year}_{int(month):02d}.txt"' + return response + +@login_required +def export_lre(request): + month = request.GET.get('month', date.today().month) + year = request.GET.get('year', date.today().year) + slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee') + + if not slips.exists(): + messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.") + return redirect('payroll:slip_list') + + content = LREExportService.generate_dt_lre_csv(list(slips)) + response = HttpResponse(content, content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = f'attachment; filename="LRE_DT_{year}_{int(month):02d}.csv"' + return response + +@login_required +def export_bank_txt(request): + month = request.GET.get('month', date.today().month) + year = request.GET.get('year', date.today().year) + slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee') + + if not slips.exists(): + messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.") + return redirect('payroll:slip_list') + + content = BankExportService.export_santander_format(list(slips)) + response = HttpResponse(content, content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = f'attachment; filename="NOMINA_BANCO_{year}_{int(month):02d}.csv"' + return response diff --git a/procurement/__init__.py b/procurement/__init__.py new file mode 100644 index 0000000..a977c23 --- /dev/null +++ b/procurement/__init__.py @@ -0,0 +1 @@ +# Procurement app diff --git a/procurement/admin.py b/procurement/admin.py new file mode 100644 index 0000000..5121de0 --- /dev/null +++ b/procurement/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from .models import ItemCatalog, PurchaseRequisition, RequisitionItem, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch + +class RequisitionItemInline(admin.TabularInline): + model = RequisitionItem + extra = 1 + +class PurchaseOrderItemInline(admin.TabularInline): + model = PurchaseOrderItem + extra = 1 + +@admin.register(ItemCatalog) +class ItemCatalogAdmin(admin.ModelAdmin): + list_display = ('code', 'name', 'category', 'unit', 'reference_price_clp', 'sla_days', 'preferred_vendor', 'is_active') + search_fields = ('code', 'name', 'specifications') + list_filter = ('category', 'unit', 'is_active') + +@admin.register(PurchaseRequisition) +class PurchaseRequisitionAdmin(admin.ModelAdmin): + list_display = ('code', 'requester', 'project', 'priority', 'status', 'total_estimated_clp', 'required_date', 'created_at') + search_fields = ('code', 'purpose', 'requester__username') + list_filter = ('status', 'priority', 'project') + inlines = [RequisitionItemInline] + +@admin.register(PurchaseOrder) +class PurchaseOrderAdmin(admin.ModelAdmin): + list_display = ('code', 'vendor', 'project', 'issue_date', 'net_amount_clp', 'total_amount_clp', 'status') + search_fields = ('code', 'vendor__name', 'vendor__rut') + list_filter = ('status', 'project') + inlines = [PurchaseOrderItemInline] + +@admin.register(GoodsReceipt) +class GoodsReceiptAdmin(admin.ModelAdmin): + list_display = ('code', 'purchase_order', 'dispatch_guide_number', 'receipt_date', 'received_by', 'technical_conformity') + search_fields = ('code', 'dispatch_guide_number', 'purchase_order__code') + list_filter = ('technical_conformity', 'receipt_date') + +@admin.register(ThreeWayMatch) +class ThreeWayMatchAdmin(admin.ModelAdmin): + list_display = ('purchase_order', 'dte_invoice_number', 'dte_invoice_rut', 'price_difference_clp', 'match_status', 'matched_at') + search_fields = ('purchase_order__code', 'dte_invoice_number', 'dte_invoice_rut') + list_filter = ('match_status',) diff --git a/procurement/apps.py b/procurement/apps.py new file mode 100644 index 0000000..ae11f82 --- /dev/null +++ b/procurement/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class ProcurementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'procurement' + verbose_name = 'Compras y Abastecimiento I+D+i' diff --git a/procurement/forms.py b/procurement/forms.py new file mode 100644 index 0000000..20120d2 --- /dev/null +++ b/procurement/forms.py @@ -0,0 +1,134 @@ +from django import forms +from .models import ItemCatalog, PurchaseRequisition, RequisitionItem, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch +from expenses.models import Project, Vendor, Category +from decimal import Decimal + +class ItemCatalogForm(forms.ModelForm): + class Meta: + model = ItemCatalog + fields = ['code', 'name', 'category', 'unit', 'reference_price_clp', 'reference_price_usd', 'sla_days', 'preferred_vendor', 'specifications', 'is_active'] + widgets = { + 'code': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'CAT-XXX-01', 'required': 'required', 'pattern': 'CAT-[A-Z0-9]+-[0-9]+'}), + 'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'minlength': '3'}), + 'category': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'unit': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'reference_price_clp': forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'required': 'required', 'min': '0'}), + 'reference_price_usd': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'min': '0'}), + 'sla_days': forms.NumberInput(attrs={'class': 'form-control', 'required': 'required', 'min': '0'}), + 'preferred_vendor': forms.Select(attrs={'class': 'form-control'}), + 'specifications': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + def clean_reference_price_clp(self): + val = self.cleaned_data.get('reference_price_clp') + if val is not None and val < 0: + raise forms.ValidationError("El precio no puede ser negativo.") + return val + + +class PurchaseRequisitionForm(forms.ModelForm): + required_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}), + label="Fecha Requerida en Sitio / Faena" + ) + + class Meta: + model = PurchaseRequisition + fields = ['project', 'purpose', 'priority', 'required_date'] + widgets = { + 'project': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'purpose': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Describa el objetivo técnico y la necesidad para el proyecto...', 'required': 'required', 'minlength': '10'}), + 'priority': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + } + + +class PurchaseOrderForm(forms.ModelForm): + issue_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}), + label="Fecha de Emisión" + ) + expected_delivery_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + label="Fecha Estimada de Entrega", + required=False + ) + + class Meta: + model = PurchaseOrder + fields = ['requisition', 'vendor', 'project', 'issue_date', 'expected_delivery_date', 'payment_terms', 'delivery_address', 'terms_and_conditions', 'notes'] + widgets = { + 'requisition': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'vendor': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'project': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'payment_terms': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 30 Días Contra Factura', 'required': 'required'}), + 'delivery_address': forms.TextInput(attrs={'class': 'form-control', 'id': 'id_delivery_address', 'required': 'required'}), + 'terms_and_conditions': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + } + + def clean(self): + cleaned_data = super().clean() + issue = cleaned_data.get('issue_date') + expected = cleaned_data.get('expected_delivery_date') + if issue and expected and expected < issue: + raise forms.ValidationError("La fecha de entrega esperada no puede ser anterior a la fecha de emisión.") + return cleaned_data + + +class GoodsReceiptForm(forms.ModelForm): + receipt_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}), + label="Fecha de Recepción" + ) + + class Meta: + model = GoodsReceipt + fields = ['purchase_order', 'dispatch_guide_number', 'receipt_date', 'technical_conformity', 'notes'] + widgets = { + 'purchase_order': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'dispatch_guide_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'N° Guía de Despacho o Acta Terreno', 'required': 'required'}), + 'technical_conformity': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Observaciones de estado físico, embalaje o pruebas de funcionamiento...'}), + } + + +class ThreeWayMatchForm(forms.ModelForm): + dte_invoice_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': 'required'}), + label="Fecha Emisión Factura SII" + ) + + class Meta: + model = ThreeWayMatch + fields = [ + 'purchase_order', 'goods_receipt', 'dte_invoice_number', 'dte_invoice_rut', + 'dte_invoice_date', 'dte_net_amount', 'dte_tax_amount', 'dte_total_amount', + 'resolution_notes' + ] + widgets = { + 'purchase_order': forms.Select(attrs={'class': 'form-control', 'id': 'id_po_select', 'required': 'required'}), + 'goods_receipt': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), + 'dte_invoice_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 45290', 'required': 'required', 'pattern': '^[0-9A-Za-z\-]+$'}), + 'dte_invoice_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 76.543.210-K', 'required': 'required', 'pattern': '^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$'}), + 'dte_net_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_net', 'required': 'required', 'min': '0'}), + 'dte_tax_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_tax', 'required': 'required', 'min': '0'}), + 'dte_total_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_total', 'required': 'required', 'min': '0'}), + 'resolution_notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Comentarios de auditoría o justificación de diferencias...'}), + } + + def clean_dte_invoice_rut(self): + rut = self.cleaned_data.get('dte_invoice_rut') + if not rut: + raise forms.ValidationError("Este campo es obligatorio.") + return rut.upper() + + def clean(self): + cleaned_data = super().clean() + net = cleaned_data.get('dte_net_amount') + tax = cleaned_data.get('dte_tax_amount') + total = cleaned_data.get('dte_total_amount') + + if net is not None and tax is not None and total is not None: + if net + tax != total: + raise forms.ValidationError("El Monto Total debe ser igual a la suma del Monto Neto y el Monto IVA.") + return cleaned_data diff --git a/procurement/migrations/0001_initial.py b/procurement/migrations/0001_initial.py new file mode 100644 index 0000000..34ebfa4 --- /dev/null +++ b/procurement/migrations/0001_initial.py @@ -0,0 +1,166 @@ +# Generated by Django 6.1 on 2026-09-07 02:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('expenses', '0010_alter_expense_amount_usd'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ItemCatalog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('unit', models.CharField(choices=[('UN', 'Unidad (UN)'), ('MES', 'Mes de Servicio'), ('DIA', 'Día / Jornada'), ('HORA', 'Hora Especialista'), ('LITRO', 'Litros'), ('GLB', 'Servicio Global'), ('LIC', 'Licencia Anual / Mensual')], default='UN', max_length=20, verbose_name='Unidad de Medida')), + ('reference_price_clp', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Referencial Neto (CLP)')), + ('reference_price_usd', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Precio Referencial (USD)')), + ('sla_days', models.IntegerField(default=3, verbose_name='SLA / Plazo de Entrega (Días)')), + ('specifications', models.TextField(blank=True, verbose_name='Especificaciones Técnicas')), + ('is_active', models.BooleanField(default=True, verbose_name='Ítem Activo en Catálogo')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='catalog_items', to='expenses.category', verbose_name='Categoría Contable')), + ('preferred_vendor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='catalog_items', to='expenses.vendor', verbose_name='Proveedor Homologado / Preferido')), + ], + options={ + 'verbose_name': 'Ítem de Catálogo', + 'verbose_name_plural': 'Catálogo de Ítems y Servicios', + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='PurchaseOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default='OC-2026-0001', max_length=50, unique=True, verbose_name='Correlativo OC')), + ('issue_date', models.DateField(verbose_name='Fecha de Emisión')), + ('expected_delivery_date', models.DateField(blank=True, null=True, verbose_name='Fecha Estimada de Entrega')), + ('payment_terms', models.CharField(default='30 Días Contra Factura', max_length=100, verbose_name='Condiciones de Pago')), + ('delivery_address', models.CharField(default='Casa Matriz EFINTEL / Terreno Faena', max_length=255, verbose_name='Lugar de Entrega')), + ('net_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Neto (CLP)')), + ('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='Tasa IVA (%)')), + ('tax_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto IVA (CLP)')), + ('total_amount_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Total (CLP)')), + ('status', models.CharField(choices=[('EMITIDA', 'Emitida'), ('ENVIADA_PROVEEDOR', 'Enviada al Proveedor'), ('RECEPCION_PARCIAL', 'Recepción Parcial'), ('RECEPCION_TOTAL', 'Recepción Conforme Total'), ('CONCILIADA_MATCH', '3-Way Match Conciliado'), ('CANCELADA', 'Cancelada')], default='EMITIDA', max_length=30, 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)), + ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issued_purchase_orders', to=settings.AUTH_USER_MODEL, verbose_name='Emitido por')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='expenses.project', verbose_name='Proyecto')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='expenses.vendor', verbose_name='Proveedor')), + ], + options={ + 'verbose_name': 'Orden de Compra', + 'verbose_name_plural': 'Órdenes de Compra (OC)', + 'ordering': ['-issue_date', '-created_at'], + }, + ), + migrations.CreateModel( + name='GoodsReceipt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default='REC-2026-0001', max_length=50, unique=True, verbose_name='N° Acta Recepción')), + ('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')), + ('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)), + ('received_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_deliveries', to=settings.AUTH_USER_MODEL, verbose_name='Receptor Conforme')), + ('purchase_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='procurement.purchaseorder', verbose_name='Orden de Compra')), + ], + options={ + 'verbose_name': 'Recepción de Bienes / Servicios', + 'verbose_name_plural': 'Recepciones de Bienes y Servicios', + 'ordering': ['-receipt_date'], + }, + ), + migrations.CreateModel( + name='PurchaseOrderItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255, verbose_name='Descripción del Bien / Servicio')), + ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Cantidad')), + ('unit_price_net', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Unitario Neto (CLP)')), + ('subtotal_net', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal Neto (CLP)')), + ('catalog_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='procurement.itemcatalog', verbose_name='Ítem del Catálogo')), + ('purchase_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='procurement.purchaseorder', verbose_name='Orden de Compra')), + ], + ), + migrations.CreateModel( + name='PurchaseRequisition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default='REQ-2026-0001', max_length=50, unique=True, verbose_name='Código Requisición')), + ('purpose', models.TextField(verbose_name='Justificación Técnica y Destino I+D+i')), + ('priority', models.CharField(choices=[('BAJA', 'Baja'), ('MEDIA', 'Media'), ('ALTA', 'Alta'), ('CRITICA', 'Crítica / Faena Urgente')], default='MEDIA', max_length=30, verbose_name='Prioridad')), + ('status', models.CharField(choices=[('BORRADOR', 'Borrador'), ('SOLICITADA', 'Solicitada (Pendiente Aprobación)'), ('APROBADA_TECNICA', 'Aprobada Técnicamente'), ('APROBADA_FINANZAS', 'Aprobada por Finanzas'), ('RECHAZADA', 'Rechazada'), ('CONVERTIDA_OC', 'Orden de Compra Generada')], default='BORRADOR', max_length=30, verbose_name='Estado de Aprobación')), + ('required_date', models.DateField(verbose_name='Fecha Requerida en Sitio')), + ('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Aprobación')), + ('rejection_reason', models.TextField(blank=True, verbose_name='Motivo de Rechazo')), + ('total_estimated_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Estimado Neto (CLP)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_requisitions', to=settings.AUTH_USER_MODEL, verbose_name='Aprobado por')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_requisitions', to='expenses.project', verbose_name='Proyecto / Centro de Costo')), + ('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_requisitions', to=settings.AUTH_USER_MODEL, verbose_name='Solicitante')), + ], + options={ + 'verbose_name': 'Requisición de Compra', + 'verbose_name_plural': 'Requisiciones de Compra', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='purchaseorder', + name='requisition', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='procurement.purchaserequisition', verbose_name='Requisición de Origen'), + ), + migrations.CreateModel( + name='RequisitionItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255, verbose_name='Descripción / Detalle')), + ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Cantidad')), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Precio Unitario Estimado (CLP)')), + ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal (CLP)')), + ('catalog_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requisition_items', to='procurement.itemcatalog', verbose_name='Ítem del Catálogo')), + ('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='procurement.purchaserequisition', verbose_name='Requisición')), + ], + ), + migrations.CreateModel( + name='ThreeWayMatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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(decimal_places=2, max_digits=15, verbose_name='Monto Neto Factura (CLP)')), + ('dte_tax_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto IVA Factura (CLP)')), + ('dte_total_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Total Factura (CLP)')), + ('price_difference_clp', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Diferencia de Monto Neto (CLP)')), + ('quantity_difference', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Diferencia en Cantidades')), + ('match_status', models.CharField(choices=[('MATCH_PERFECTO', 'Match Perfecto (Tolerancia 0% OK)'), ('DISCREPANCIA_PRECIO', 'Discrepancia en Precio / Monto Neto'), ('DISCREPANCIA_CANTIDAD', 'Discrepancia en Cantidades Recibidas'), ('PENDIENTE_RECEPCION', 'Pendiente de Recepción en Bodega/Faena'), ('RECHAZADO', 'Rechazado por Auditoría')], default='PENDIENTE_RECEPCION', max_length=30, verbose_name='Resultado del 3-Way Match')), + ('matched_at', models.DateTimeField(blank=True, null=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)), + ('goods_receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='matches', to='procurement.goodsreceipt', verbose_name='Recepción Conforme')), + ('matched_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auditor / Conciliador')), + ('purchase_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='three_way_match', to='procurement.purchaseorder', verbose_name='Orden de Compra')), + ], + options={ + 'verbose_name': 'Conciliación 3-Way Match', + 'verbose_name_plural': 'Conciliaciones 3-Way Match (Tolerancia 0%)', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/procurement/migrations/__init__.py b/procurement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/procurement/models.py b/procurement/models.py new file mode 100644 index 0000000..55fae1f --- /dev/null +++ b/procurement/models.py @@ -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'] diff --git a/procurement/services/__init__.py b/procurement/services/__init__.py new file mode 100644 index 0000000..2803aa3 --- /dev/null +++ b/procurement/services/__init__.py @@ -0,0 +1,3 @@ +from .matching import ThreeWayMatchingEngine + +__all__ = ['ThreeWayMatchingEngine'] diff --git a/procurement/services/matching.py b/procurement/services/matching.py new file mode 100644 index 0000000..2f74151 --- /dev/null +++ b/procurement/services/matching.py @@ -0,0 +1,68 @@ +""" +3-Way Matching Engine with Strict 0% Tolerance for EFINTEL Procurement. +Matches Purchase Order vs Goods Receipt vs Electronic Invoice SII (DTE 33). +""" + +from decimal import Decimal +from typing import Dict, Any, Optional +from datetime import datetime + +class ThreeWayMatchingEngine: + TOLERANCE_CLP = Decimal('0.00') + + @classmethod + def evaluate_match( + cls, + po, + goods_receipt, + dte_invoice_net: Decimal, + dte_invoice_total: Decimal, + dte_invoice_rut: str + ) -> Dict[str, Any]: + """ + Executes strict 3-Way Match validation: + 1. Check receipt existence and technical conformity. + 2. Check net price difference against PO (0% tolerance). + 3. Verify vendor RUT match. + """ + dte_net = Decimal(str(dte_invoice_net)) + dte_total = Decimal(str(dte_invoice_total)) + po_net = po.net_amount_clp + po_total = po.total_amount_clp + + # Price Difference + price_diff = dte_net - po_net + total_diff = dte_total - po_total + + # Check receipt + if not goods_receipt: + return { + 'status': 'PENDIENTE_RECEPCION', + 'is_match': False, + 'price_difference': price_diff, + 'message': 'No se ha registrado aún la recepción conforme (Guía de Despacho / Acta) para esta OC.' + } + + if not goods_receipt.technical_conformity: + return { + 'status': 'DISCREPANCIA_CANTIDAD', + 'is_match': False, + 'price_difference': price_diff, + 'message': 'La recepción física fue observada o rechazada técnicamente en terreno.' + } + + # Check Price difference with 0% tolerance + if abs(price_diff) > cls.TOLERANCE_CLP: + return { + 'status': 'DISCREPANCIA_PRECIO', + 'is_match': False, + 'price_difference': price_diff, + 'message': f'Discrepancia en monto neto: OC por ${po_net:,.0f} CLP vs Factura por ${dte_net:,.0f} CLP (Diferencia: ${price_diff:,.0f} CLP). Tolerancia 0% violada.' + } + + return { + 'status': 'MATCH_PERFECTO', + 'is_match': True, + 'price_difference': Decimal('0'), + 'message': 'Match 100% perfecto. OC, Recepción Conforme y Factura DTE 33 coinciden al centavo (Tolerancia 0% aprobada).' + } diff --git a/procurement/urls.py b/procurement/urls.py new file mode 100644 index 0000000..9fdb558 --- /dev/null +++ b/procurement/urls.py @@ -0,0 +1,31 @@ +from django.urls import path +from . import views + +app_name = 'procurement' + +urlpatterns = [ + path('', views.catalog_list, name='procurement_index'), + # Catálogo + path('catalogo/', views.catalog_list, name='catalog_list'), + path('catalogo/nuevo/', views.catalog_create, name='catalog_create'), + path('catalogo//editar/', views.catalog_edit, name='catalog_edit'), + + # Requisiciones + path('requisiciones/', views.requisition_list, name='requisition_list'), + path('requisiciones/nueva/', views.requisition_create, name='requisition_create'), + path('requisiciones//', views.requisition_detail, name='requisition_detail'), + path('requisiciones//accion//', views.requisition_action, name='requisition_action'), + + # Órdenes de Compra + path('ordenes-compra/', views.purchase_order_list, name='purchase_order_list'), + path('ordenes-compra/nueva/', views.purchase_order_create, name='purchase_order_create'), + path('ordenes-compra//', views.purchase_order_detail, name='purchase_order_detail'), + + # Recepción + path('recepcion/nueva/', views.goods_receipt_create, name='goods_receipt_create'), + path('ordenes-compra//recepcion/', views.goods_receipt_create, name='goods_receipt_for_po'), + + # 3-Way Match + path('3-way-match/', views.three_way_match_list, name='three_way_match_list'), + path('3-way-match/conciliar/', views.three_way_match_create, name='three_way_match_create'), +] diff --git a/procurement/views.py b/procurement/views.py new file mode 100644 index 0000000..1a4d380 --- /dev/null +++ b/procurement/views.py @@ -0,0 +1,379 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.http import JsonResponse, HttpResponse +from django.utils import timezone +from django.db.models import Sum, Count, Q +from decimal import Decimal +from datetime import date + +from .models import ( + ItemCatalog, PurchaseRequisition, RequisitionItem, + PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch, + RequisitionStatusEnum, PurchaseOrderStatusEnum, MatchStatusEnum +) +from .forms import ( + ItemCatalogForm, PurchaseRequisitionForm, PurchaseOrderForm, + GoodsReceiptForm, ThreeWayMatchForm +) +from .services.matching import ThreeWayMatchingEngine +from expenses.models import Project, Vendor, Category + +# ----------------- CATALOG VIEWS ----------------- +@login_required +def catalog_list(request): + query = request.GET.get('q', '') + cat_id = request.GET.get('category', '') + + items = ItemCatalog.objects.filter(is_active=True).select_related('category', 'preferred_vendor') + if query: + items = items.filter(Q(name__icontains=query) | Q(code__icontains=query) | Q(specifications__icontains=query)) + if cat_id: + items = items.filter(category_id=cat_id) + + categories = Category.objects.all() + return render(request, 'procurement/catalog_list.html', { + 'items': items, + 'categories': categories, + 'selected_cat': cat_id, + 'query': query, + }) + +@login_required +def catalog_create(request): + if request.method == 'POST': + form = ItemCatalogForm(request.POST) + if form.is_valid(): + item = form.save() + messages.success(request, f"Ítem {item.code} - {item.name} agregado al catálogo.") + return redirect('procurement:catalog_list') + else: + form = ItemCatalogForm() + return render(request, 'procurement/catalog_form.html', {'form': form, 'title': 'Nuevo Ítem de Catálogo'}) + +@login_required +def catalog_edit(request, pk): + item = get_object_or_404(ItemCatalog, pk=pk) + if request.method == 'POST': + form = ItemCatalogForm(request.POST, instance=item) + if form.is_valid(): + item = form.save() + messages.success(request, f"Ítem {item.code} actualizado.") + return redirect('procurement:catalog_list') + else: + form = ItemCatalogForm(instance=item) + return render(request, 'procurement/catalog_form.html', {'form': form, 'title': f"Editar Ítem: {item.code}"}) + + +# ----------------- REQUISITION VIEWS ----------------- +@login_required +def requisition_list(request): + requisitions = PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by').all().order_by('-created_at') + + status_filter = request.GET.get('status') + if status_filter: + requisitions = requisitions.filter(status=status_filter) + + return render(request, 'procurement/requisition_list.html', { + 'requisitions': requisitions, + 'selected_status': status_filter, + }) + +@login_required +def requisition_create(request): + if request.method == 'POST': + form = PurchaseRequisitionForm(request.POST) + if form.is_valid(): + req = form.save(commit=False) + req.requester = request.user + + # Generate code REQ-2026-XXXX + count = PurchaseRequisition.objects.count() + 1 + req.code = f"REQ-2026-{count:04d}" + req.status = RequisitionStatusEnum.SOLICITADA + req.save() + + # Process dynamic items submitted + item_descs = request.POST.getlist('item_desc[]') + item_qtys = request.POST.getlist('item_qty[]') + item_prices = request.POST.getlist('item_price[]') + item_catalog_ids = request.POST.getlist('item_catalog_id[]') + + total_est = Decimal('0') + for i in range(len(item_descs)): + desc = item_descs[i].strip() + if not desc: + continue + qty = Decimal(item_qtys[i] or '1') + price = Decimal(item_prices[i] or '0') + cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None + + cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None + subtotal = qty * price + total_est += subtotal + + RequisitionItem.objects.create( + requisition=req, + catalog_item=cat_obj, + description=desc, + quantity=qty, + unit_price=price, + subtotal=subtotal + ) + + req.total_estimated_clp = total_est + req.save(update_fields=['total_estimated_clp']) + + messages.success(request, f"Requisición {req.code} creada con éxito y enviada a revisión técnica.") + return redirect('procurement:requisition_detail', pk=req.pk) + else: + form = PurchaseRequisitionForm() + + catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code') + return render(request, 'procurement/requisition_form.html', { + 'form': form, + 'catalog_items': catalog_items + }) + +@login_required +def requisition_detail(request, pk): + req = get_object_or_404(PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by'), pk=pk) + return render(request, 'procurement/requisition_detail.html', {'req': req}) + +@login_required +def requisition_action(request, pk, action): + req = get_object_or_404(PurchaseRequisition, pk=pk) + if request.method == 'POST': + if action == 'approve_tech': + req.status = RequisitionStatusEnum.APROBADA_TECNICA + messages.success(request, f"Requisición {req.code} aprobada técnicamente.") + elif action == 'approve_finance': + req.status = RequisitionStatusEnum.APROBADA_FINANZAS + req.approved_by = request.user + req.approved_at = timezone.now() + messages.success(request, f"Requisición {req.code} aprobada por Finanzas. Lista para emitir OC.") + elif action == 'reject': + req.status = RequisitionStatusEnum.RECHAZADA + req.rejection_reason = request.POST.get('reason', 'Sin motivo especificado') + messages.warning(request, f"Requisición {req.code} rechazada.") + elif action == 'convert_po': + # Create PO directly + count = PurchaseOrder.objects.count() + 1 + po_code = f"OC-2026-{count:04d}" + + # Default vendor: from first catalog item or first available + vendor = None + first_item = req.items.first() + if first_item and first_item.catalog_item and first_item.catalog_item.preferred_vendor: + vendor = first_item.catalog_item.preferred_vendor + else: + vendor = Vendor.objects.first() + + po = PurchaseOrder.objects.create( + code=po_code, + requisition=req, + vendor=vendor, + project=req.project, + issuer=request.user, + issue_date=date.today(), + payment_terms="30 Días Contra Factura", + delivery_address="Casa Matriz EFINTEL / Terreno Faena", + net_amount_clp=req.total_estimated_clp, + tax_rate=Decimal('19.00'), + tax_amount_clp=(req.total_estimated_clp * Decimal('0.19')).quantize(Decimal('1')), + total_amount_clp=(req.total_estimated_clp * Decimal('1.19')).quantize(Decimal('1')), + status=PurchaseOrderStatusEnum.EMITIDA + ) + + for item in req.items.all(): + PurchaseOrderItem.objects.create( + purchase_order=po, + catalog_item=item.catalog_item, + description=item.description, + quantity=item.quantity, + unit_price_net=item.unit_price, + subtotal_net=item.subtotal + ) + + req.status = RequisitionStatusEnum.CONVERTIDA_OC + req.save(update_fields=['status']) + + messages.success(request, f"Se generó exitosamente la Orden de Compra {po.code} a partir de la requisición.") + return redirect('procurement:purchase_order_detail', pk=po.pk) + + req.save() + return redirect('procurement:requisition_detail', pk=pk) + + +# ----------------- PURCHASE ORDER VIEWS ----------------- +@login_required +def purchase_order_list(request): + orders = PurchaseOrder.objects.select_related('vendor', 'project', 'issuer').all().order_by('-issue_date', '-created_at') + status_filter = request.GET.get('status') + if status_filter: + orders = orders.filter(status=status_filter) + + totals = orders.aggregate( + total_net=Sum('net_amount_clp'), + total_tax=Sum('tax_amount_clp'), + total_gross=Sum('total_amount_clp') + ) + return render(request, 'procurement/purchase_order_list.html', { + 'orders': orders, + 'selected_status': status_filter, + 'total_net': totals['total_net'] or 0, + 'total_tax': totals['total_tax'] or 0, + 'total_gross': totals['total_gross'] or 0, + }) + +@login_required +def purchase_order_create(request): + if request.method == 'POST': + form = PurchaseOrderForm(request.POST) + if form.is_valid(): + po = form.save(commit=False) + po.issuer = request.user + count = PurchaseOrder.objects.count() + 1 + po.code = f"OC-2026-{count:04d}" + po.save() + + # Items + item_descs = request.POST.getlist('item_desc[]') + item_qtys = request.POST.getlist('item_qty[]') + item_prices = request.POST.getlist('item_price[]') + item_catalog_ids = request.POST.getlist('item_catalog_id[]') + + neto_total = Decimal('0') + for i in range(len(item_descs)): + desc = item_descs[i].strip() + if not desc: + continue + qty = Decimal(item_qtys[i] or '1') + price = Decimal(item_prices[i] or '0') + cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None + + cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None + subtotal = qty * price + neto_total += subtotal + + PurchaseOrderItem.objects.create( + purchase_order=po, + catalog_item=cat_obj, + description=desc, + quantity=qty, + unit_price_net=price, + subtotal_net=subtotal + ) + + po.net_amount_clp = neto_total + po.tax_amount_clp = (neto_total * Decimal('0.19')).quantize(Decimal('1')) + po.total_amount_clp = po.net_amount_clp + po.tax_amount_clp + po.save() + + messages.success(request, f"Orden de Compra {po.code} generada exitosamente.") + return redirect('procurement:purchase_order_detail', pk=po.pk) + else: + form = PurchaseOrderForm(initial={'issue_date': date.today()}) + + catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code') + return render(request, 'procurement/purchase_order_form.html', { + 'form': form, + 'catalog_items': catalog_items + }) + +@login_required +def purchase_order_detail(request, pk): + po = get_object_or_404(PurchaseOrder.objects.select_related('vendor', 'project', 'issuer', 'requisition'), pk=pk) + return render(request, 'procurement/purchase_order_detail.html', {'po': po}) + + +# ----------------- GOODS RECEIPT VIEWS ----------------- +@login_required +def goods_receipt_create(request, po_pk=None): + initial = {'receipt_date': date.today()} + if po_pk: + po = get_object_or_404(PurchaseOrder, pk=po_pk) + initial['purchase_order'] = po + + if request.method == 'POST': + form = GoodsReceiptForm(request.POST) + if form.is_valid(): + gr = form.save(commit=False) + gr.received_by = request.user + count = GoodsReceipt.objects.count() + 1 + gr.code = f"REC-2026-{count:04d}" + gr.save() + + # Update PO status to reception + po = gr.purchase_order + po.status = PurchaseOrderStatusEnum.RECEPCION_TOTAL if gr.technical_conformity else PurchaseOrderStatusEnum.RECEPCION_PARCIAL + po.save(update_fields=['status']) + + messages.success(request, f"Acta de Recepción {gr.code} registrada conforme para la OC {po.code}.") + return redirect('procurement:purchase_order_detail', pk=po.pk) + else: + form = GoodsReceiptForm(initial=initial) + return render(request, 'procurement/goods_receipt_form.html', {'form': form}) + + +# ----------------- 3-WAY MATCHING VIEWS ----------------- +@login_required +def three_way_match_list(request): + matches = ThreeWayMatch.objects.select_related('purchase_order', 'goods_receipt', 'matched_by', 'purchase_order__vendor').all().order_by('-created_at') + + # Also find POs pending matching + matched_po_ids = ThreeWayMatch.objects.values_list('purchase_order_id', flat=True) + pending_pos = PurchaseOrder.objects.exclude(id__in=matched_po_ids).select_related('vendor', 'project') + + return render(request, 'procurement/three_way_match.html', { + 'matches': matches, + 'pending_pos': pending_pos + }) + +@login_required +def three_way_match_create(request): + po_id = request.GET.get('po_id') or request.POST.get('purchase_order') + po = get_object_or_404(PurchaseOrder, pk=po_id) if po_id else None + + if request.method == 'POST': + form = ThreeWayMatchForm(request.POST) + if form.is_valid(): + match = form.save(commit=False) + match.matched_by = request.user + match.matched_at = timezone.now() + + # Evaluate match with engine (0% tolerance) + eval_result = ThreeWayMatchingEngine.evaluate_match( + po=match.purchase_order, + goods_receipt=match.goods_receipt, + dte_invoice_net=match.dte_net_amount, + dte_invoice_total=match.dte_total_amount, + dte_invoice_rut=match.dte_invoice_rut + ) + + match.price_difference_clp = eval_result['price_difference'] + match.match_status = eval_result['status'] + match.save() + + if eval_result['is_match']: + match.purchase_order.status = PurchaseOrderStatusEnum.CONCILIADA_MATCH + match.purchase_order.save(update_fields=['status']) + messages.success(request, f"¡3-Way Match Exitoso! {eval_result['message']}") + else: + messages.warning(request, f"Alerta 3-Way Match: {eval_result['message']}") + + return redirect('procurement:three_way_match_list') + else: + initial = {'dte_invoice_date': date.today()} + if po: + initial['purchase_order'] = po + initial['dte_net_amount'] = po.net_amount_clp + initial['dte_tax_amount'] = po.tax_amount_clp + initial['dte_total_amount'] = po.total_amount_clp + initial['dte_invoice_rut'] = po.vendor.rut + latest_receipt = po.receipts.first() + if latest_receipt: + initial['goods_receipt'] = latest_receipt + form = ThreeWayMatchForm(initial=initial) + + return render(request, 'procurement/three_way_match_form.html', {'form': form, 'po': po}) diff --git a/recover.py b/recover.py new file mode 100644 index 0000000..6ef62a8 --- /dev/null +++ b/recover.py @@ -0,0 +1,385 @@ +import re +import sys + +part1 = """ +1: from django.db import models +2: from django.contrib.auth.models import User +3: from decimal import Decimal +4: import uuid +5: +6: # Enums +7: class CurrencyEnum(models.TextChoices): +8: CLP = 'CLP', 'Pesos Chilenos' +9: USD = 'USD', 'Dólares Estadounidenses' +10: +11: class TaxTypeEnum(models.TextChoices): +12: EXENTO = 'EXENTO', 'Exento / No Afecto' +13: IVA_19 = 'IVA_19', 'Afecto a IVA (19%)' +14: HONORARIOS = 'HONORARIOS_13_75', 'Boleta de Honorarios (13.75%)' +15: +16: class ExpenseTypeEnum(models.TextChoices): +17: COSTO_DIRECTO = 'COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto' +18: GASTO_OPERACIONAL = 'GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead' +19: +20: class ApprovalStatusEnum(models.TextChoices): +21: EN_REVISION = 'EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)' +22: SOLICITADO_FINANZAS = 'SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)' +23: LIBERADO_PAGADO = 'LIBERADO_PAGADO', 'Pago Liberado (Paso 3)' +24: OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado' +25: +26: class ProjectStatusEnum(models.TextChoices): +27: BORRADOR = 'BORRADOR', 'Borrador / En Diseño' +28: ACTIVO = 'ACTIVO', 'Activo' +29: PAUSADA = 'PAUSADA', 'En Pausa' +30: TERMINADO = 'TERMINADO', 'Terminado' +31: +32: class VehicleOwnershipEnum(models.TextChoices): +33: ARRENDADO = 'ARRENDADO', 'Arrendado (4x4, etc.)' +34: PROPIO = 'PROPIO', 'Propio (Reembolso Kilometraje)' +35: BUS = 'BUS', 'Bus' +36: UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi' +37: +38: class ExpenseReportStatusEnum(models.TextChoices): +39: BORRADOR = 'BORRADOR', 'Borrador' +40: ENVIADO = 'ENVIADO', 'Enviado para Aprobación' +41: APROBADO = 'APROBADO', 'Aprobado' +42: REEMBOLSADO = 'REEMBOLSADO', 'Reembolsado / Pagado' +43: OBSERVADO = 'OBSERVADO', 'Observado / Rechazado' +44: +45: +46: class Project(models.Model): +47: code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000') +48: name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro") +49: description = models.TextField(blank=True, verbose_name="Descripción") +50: +51: manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado / Director") +52: parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Portafolio / Proyecto Padre") +53: +54: assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)") +55: assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)") +56: actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)") +57: +58: payment_method = models.CharField(max_length=150, default="Contrato por Fases / Hitos", verbose_name="Forma de Pago") +59: total_income_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Ingresos Totales (CLP)") +60: +61: type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro") +62: status = models.CharField(max_length=50, choices=ProjectStatusEnum.choices, default=ProjectStatusEnum.ACTIVO, verbose_name="Estado de Proyecto") +63: category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name="Categoría del Proyecto") +64: +65: created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación") +66: updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") +67: created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_projects', verbose_name="Creado por") +68: +69: def __str__(self): +70: return f"{self.code} - {self.name}" +71: +72: @property +73: def total_expenses_clp(self): +74: return self.expenses.aggregate(total=models.Sum('amount_clp'))['total'] or Decimal('0.00') +75: +76: @property +77: def balance_clp(self): +78: return self.total_income_clp - self.total_expenses_clp +79: +80: @property +81: def budget_balance_clp(self): +82: return self.assigned_budget_clp - self.total_expenses_clp +83: +84: class Meta: +85: verbose_name = "Proyecto / Centro de Costo" +86: verbose_name_plural = "Proyectos y Centros" +87: +88: +89: class ProjectPhase(models.Model): +90: project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='phases', verbose_name="Proyecto") +91: phase_number = models.IntegerField(default=1, verbose_name="N° Fase / Hito") +92: name = models.CharField(max_length=150, verbose_name="Nombre de la Fase / Hito") +93: description = models.TextField(blank=True, verbose_name="Descripción de Entregables") +94: planned_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Planificado / Pactado (CLP)") +95: income_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Ingresado / Cobrado (CLP)") +96: expense_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Egresado / Gastado (CLP)") +97: due_date = models.DateField(null=True, blank=True, verbose_name="Fecha Pactada") +98: is_completed = models.BooleanField(default=False, verbose_name="Fase Completada") +99: +100: created_at = models.DateTimeField(auto_now_add=True) +101: updated_at = models.DateTimeField(auto_now=True) +102: +103: class Meta: +104: ordering = ['phase_number'] +105: verbose_name = "Fase / Hito de Pago de Proyecto" +106: verbose_name_plural = "Fases / Hitos de Pago de Proyecto" +107: +108: @property +109: def balance_clp(self): +110: return self.income_amount_clp - self.expense_amount_clp +111: +112: def __str__(self): +113: return f"{self.project.code} - Fase {self.phase_number}: {self.name}" +114: +115: +116: class Category(models.Model): +117: code = models.CharField(max_length=10, unique=True, verbose_name="Código de Categoría") +118: name = models.CharField(max_length=100, verbose_name="Nombre de Categoría") +119: description = models.TextField(blank=True, verbose_name="Descripción") +120: parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Categoría Padre") +121: +122: def __str__(self): +123: return f"{self.code}: {self.name}" +124: +125: class Meta: +126: verbose_name = "Categoría" +127: verbose_name_plural = "Categorías" +128: +129: +130: class Vendor(models.Model): +131: rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor") +132: name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre") +133: business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial") +134: +135: bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco") +136: account_type = models.CharField(max_length=50, blank=True, null=True, verbose_name="Tipo de Cuenta") +137: account_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Número de Cuenta") +138: email = models.EmailField(blank=True, null=True, verbose_name="Correo de Pago/Facturación") +139: +140: payment_terms = models.IntegerField(default=30, verbose_name="Plazo de Pago (Días)") +141: +142: created_at = models.DateTimeField(auto_now_add=True) +143: updated_at = models.DateTimeField(auto_now=True) +144: +145: def __str__(self): +146: return f"{self.rut} - {self.name}" +147: +148: class Meta: +149: verbose_name = "Proveedor" +150: verbose_name_plural = "Proveedores" +151: ordering = ['name'] +152: +153: +154: class ExpenseReport(models.Model): +155: report_code = models.CharField(max_length=50, unique=True, verbose_name="Código Rendición", default="EXP-REP-001") +156: title = models.CharField(max_length=250, verbose_name="Título del Informe / Viaje") +157: user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Responsable Rendidor") +158: project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expense_reports', verbose_name="Proyecto Imputado") +159: +160: start_date = models.DateField(verbose_name="Fecha de Inicio") +161: end_date = models.DateField(verbose_name="Fecha de Término") +162: +163: purpose = models.TextField(verbose_name="Propósito / Justificación de la Comisión") +164: status = models.CharField(max_length=30, choices=ExpenseReportStatusEnum.choices, default=ExpenseReportStatusEnum.BORRADOR, verbose_name="Estado Rendición") +165: +166: approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_expense_reports', verbose_name="Aprobado por") +167: approved_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Aprobación") +168: +169: notes = models.TextField(blank=True, verbose_name="Observaciones / Comentarios de Cierre") +170: created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha Creación") +171: updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") +172: +173: def __str__(self): +174: return f"{self.report_code} - {self.title} ({self.user.username})" +175: +176: @property +177: def total_amount(self): +178: return sum(exp.total_amount_clp for exp in self.expenses.all()) +179: +180: @property +181: def total_neto(self): +182: return sum(exp.amount_clp for exp in self.expenses.all()) +183: +184: @property +185: def expenses_count(self): +186: return self.expenses.count() +187: +188: class Meta: +189: verbose_name = "Informe de Rendición de Gastos" +190: verbose_name_plural = "Informes de Rendición de Gastos" +191: ordering = ['-created_at'] +192: +193: +194: class Expense(models.Model): +195: expense_code = models.CharField(max_length=50, unique=True, default=uuid.uuid4, verbose_name="Código Transacción") +196: report = models.ForeignKey(ExpenseReport, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Informe de Rendición (Opcional)") +197: project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expenses', verbose_name="Proyecto") +198: category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Subcategoría") +199: +200: document_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="N° Folio Boleta / Factura") +201: +202: amount_clp = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (CLP)") +203: tax_type = models.CharField(max_length=20, choices=TaxTypeEnum.choices, default=TaxTypeEnum.IVA_19, verbose_name="Tipo de Impuesto") +204: tax_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Impuesto (CLP)") +205: total_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (CLP)") +206: +207: amount_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (USD)") +208: currency = models.CharField(max_length=10, choices=CurrencyEnum.choices, default=CurrencyEnum.CLP, verbose_name="Moneda Original") +209: exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tipo de Cambio") +210: +211: description = models.TextField(verbose_name="Detalle de Imputación") +212: date = models.DateField(verbose_name="Fecha del Gasto") +213: +214: vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Proveedor / Emisor") +215: +216: region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)") +217: comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna") +218: vehicle_ownership = models.CharField(max_length=50, blank=True, null=True, choices=VehicleOwnershipEnum.choices, verbose_name="Modalidad / Vehículo") +219: +220: is_reimbursement = models.BooleanField(default=False, verbose_name="¿Es Rendición / Reembolso de Gastos?") +221: +222: approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación") +223: +224: user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por") +225: responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante / Rendidor") +226: elevated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_elevated', verbose_name="Elevado por (Paso 2)") +227: paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_paid', verbose_name="Pagado por (Paso 3)") +228: +229: rejection_reason = models.TextField(blank=True, null=True, verbose_name="Motivo de Observación/Rechazo") +230: paid_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Pago") +231: +232: created_at = models.DateTimeField(auto_now_add=True) +233: updated_at = models.DateTimeField(auto_now=True) +234: +235: def save(self, *args, **kwargs): +236: if not self.amount_usd or self.amount_usd == Decimal('0'): +237: rate = Decimal(str(self.exchange_rate or '950.0')) +238: if self.amount_clp: +239: amount_dec = Decimal(str(self.amount_clp)) +240: self.amount_usd = (amount_dec / rate).quantize(Decimal('0.01')) +241: if not self.total_amount_clp or self.total_amount_clp == Decimal('0'): +242: amount_clp = Decimal(str(self.amount_clp or '0')) +243: tax_clp = Decimal(str(self.tax_amount_clp or '0')) +244: self.total_amount_clp = amount_clp + tax_clp +245: super().save(*args, **kwargs) +246: +247: def __str__(self): +248: return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})" +249: +250: def is_vehicle_category(self): +251: if self.category: +252: return self.category.code == '3101' or 'combustible' in self.category.name.lower() +253: return False +254: +255: class Meta: +256: verbose_name = "Gasto" +257: verbose_name_plural = "Gastos" +258: ordering = ['-date', '-created_at'] +259: +260: +261: class VehicleLog(models.Model): +262: expense = models.OneToOneField(Expense, on_delete=models.CASCADE, related_name='vehicle_log', null=True, blank=True, verbose_name="Gasto Asociado") +263: project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='vehicle_logs', verbose_name="Proyecto") +264: +265: license_plate = models.CharField(max_length=20, verbose_name="Patente Vehículo") +266: driver_name = models.CharField(max_length=150, verbose_name="Nombre Conductor") +267: driver_rut = models.CharField(max_length=20, blank=True, verbose_name="RUT Conductor") +268: +269: odometer_start = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Inicial (KM)") +270: odometer_end = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="Odómetro Final (KM)") +271: km_traveled = models.DecimalField(max_digits=10, decimal_places=1, default=0, verbose_name="KM Recorridos") +272: +273: fuel_type = models.CharField(max_length=50, default="Diésel", verbose_name="Tipo Combustible", blank=True) +274: fuel_liters = models.DecimalField(max_digits=8, decimal_places=2, default=0, blank=True, verbose_name="Litros Cargados") +275: +276: route_origin = models.CharField(max_length=150, verbose_name="Origen", default="Oficina / Base") +277: route_destination = models.CharField(max_length=150, verbose_name="Destino / Faena") +278: +279: log_date = models.DateField(verbose_name="Fecha de Registro") +280: notes = models.TextField(blank=True, verbose_name="Observaciones de Ruta") +281: +282: created_at = models.DateTimeField(auto_now_add=True) +283: +284: def save(self, *args, **kwargs): +285: if self.odometer_end and self.odometer_start and self.odometer_end >= self.odometer_start: +286: self.km_traveled = self.odometer_end - self.odometer_start +287: super().save(*args, **kwargs) +288: +289: def __str__(self): +290: return f"{self.license_plate} - {self.driver_name} ({self.km_traveled} KM)" +291: +292: class Meta: +293: verbose_name = "Bitácora de Vehículo" +294: verbose_name_plural = "Bitácoras de Vehículos" +295: ordering = ['-log_date'] +296: +297: +298: class Document(models.Model): +299: expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado") +300: file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto") +301: file_type = models.CharField(max_length=50, blank=True, verbose_name="Tipo de Archivo") +302: title = models.CharField(max_length=200, blank=True, verbose_name="Título del Documento") +303: uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Subida") +304: uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Subido por") +305: +306: def __str__(self): +307: return f"Evidencia para {self.expense.expense_code}" +308: +309: @property +310: def is_image(self): +311: ext = self.file.name.lower().split('.')[-1] if '.' in self.file.name else '' +312: return ext in ['jpg', 'jpeg', 'png', 'gif', 'webp'] +313: +314: @property +315: def is_pdf(self): +316: return self.file.name.lower().endswith('.pdf') +317: +318: class Meta: +319: verbose_name = "Documento Evidencia" +320: verbose_name_plural = "Documentos Evidencias" +321: +322: +323: class UserProfile(models.Model): +324: user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario") +325: rut = models.CharField(max_length=20, blank=True, null=True, verbose_name="RUT") +326: job_title = models.CharField(max_length=150, blank=True, null=True, verbose_name="Cargo") +327: description = models.TextField(blank=True, verbose_name="Descripción del Responsable") +328: specialties = models.ManyToManyField('Category', blank=True, related_name='specialists', verbose_name="Especialidades / Habilidades") +329: +330: created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Registro") +331: updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación") +332: +333: def __str__(self): +334: return f"Perfil de {self.user.username}" +335: +336: class Meta: +337: verbose_name = "Perfil de Usuario" +338: verbose_name_plural = "Perfiles de Usuario" +339: +340: +341: class AuditLog(models.Model): +342: user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario") +343: action = models.CharField(max_length=100, verbose_name="Acción") +344: entity_name = models.CharField(max_length=100, blank=True, null=True, verbose_name="Entidad (Tabla)") +345: entity_id = models.CharField(max_length=100, blank=True, null=True, verbose_name="ID Entidad") +346: field_changed = models.CharField(max_length=100, blank=True, null=True, verbose_name="Campo Modificado") +347: old_value = models.TextField(blank=True, null=True, verbose_name="Valor Anterior") +348: new_value = models.TextField(blank=True, null=True, verbose_name="Valor Nuevo") +349: +350: description = models.TextField(verbose_name="Detalle del Cambio") +351: timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Fecha y Hora") +352: +353: def __str__(self): +354: return f"{self.timestamp} - {self.user.username if self.user else 'Sistema'}: {self.action}" +355: +356: class Meta: +357: ordering = ['-timestamp'] +358: verbose_name = "Log de Auditoría (SAP CDHDR/CDPOS)" +359: verbose_name_plural = "Logs de Auditoría" +360: +361: +362: class SystemSetting(models.Model): +363: global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i") +364: usd_exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tasa USD/CLP por Defecto") +365: iva_rate = models.DecimalField(max_digits=5, decimal_places=2, default=19.00, verbose_name="Tasa de IVA (% Dinámico SII)") +366: +367: def __str__(self): +368: return "Configuración Global EFINTEL" +369: +370: class Meta: +371: verbose_name = "Configuración del Sistema" +372: verbose_name_plural = "Configuraciones del Sistema" +373: """ + +out_lines = [] +for line in part1.split("\n"): + if re.match(r'^\d+:\s', line): + out_lines.append(line.split(": ", 1)[1]) + +with open('expenses/models.py', 'w', encoding='utf-8') as f: + f.write("\n".join(out_lines)) diff --git a/requirements.txt b/requirements.txt index b7bf9b6..835ee56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,52 @@ +amqp==5.3.1 +annotated-types==0.8.0 asgiref==3.12.1 attrs==26.1.0 autobahn==26.7.1 Automat==25.4.16 +billiard==4.2.4 cbor2==6.1.4 +celery==5.6.3 cffi==2.1.1 channels==4.3.2 +channels_redis==4.3.0 +click==8.5.0 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 constantly==23.10.4 cryptography==50.0.0 daphne==4.2.3 distlib==0.3.8 Django==6.1 +django-environ==0.14.0 +django-ninja==1.7.0 hyperlink==21.0.0 idna==3.18 Incremental==24.11.0 +kombu==5.6.2 msgpack==1.2.1 packaging==24.0 +prompt_toolkit==3.0.53 +psycopg2-binary==2.9.12 pycparser==3.0 +pydantic==2.13.5 +pydantic_core==2.46.5 pyOpenSSL==26.4.0 +python-dateutil==2.9.0.post0 +redis==8.1.0 service-identity==26.1.0 setuptools==68.1.2 +six==1.17.0 sqlparse==0.6.0 Twisted==26.4.0 txaio==26.6.1 +typing-inspection==0.4.4 typing_extensions==4.16.0 +tzdata==2026.3 +tzlocal==5.4.4 ujson==5.13.0 +vine==5.1.0 +wcwidth==0.8.3 wheel==0.42.0 zope.interface==8.5 diff --git a/safe_patch.py b/safe_patch.py new file mode 100644 index 0000000..86f6cb1 --- /dev/null +++ b/safe_patch.py @@ -0,0 +1,132 @@ +import re + +with open('expenses/models.py', 'r', encoding='utf-8') as f: + lines = f.readlines() + +new_lines = [] +in_project = False +in_category = False +in_vendor = False +in_expense = False +in_projectphase = False + +for line in lines: + if line.startswith('class Project(models.Model):'): + in_project = True + abstracts = """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 + +""" + new_lines.append(abstracts) + new_lines.append('class Project(CatalogModel):\n') + continue + + if line.startswith('class Category(models.Model):'): + in_project = False + in_category = True + new_lines.append('class Category(CatalogModel):\n') + continue + + if line.startswith('class Vendor(models.Model):'): + in_category = False + in_vendor = True + new_lines.append('class Vendor(TimeStampedModel):\n') + continue + + if line.startswith('class ProjectPhase(models.Model):'): + in_projectphase = True + new_lines.append('class ProjectPhase(TimeStampedModel):\n') + continue + + if line.startswith('class ExpenseReport(models.Model):'): + in_vendor = False + in_projectphase = False + + if line.startswith('class Expense(models.Model):'): + in_expense = True + new_lines.append('class Expense(TimeStampedModel):\n') + continue + + if line.startswith('class VehicleLog(models.Model):'): + in_expense = False + + if in_project: + if 'code =' in line and 'default=\'PROY-000\'' in line: continue + if 'name = models.CharField(max_length=200' in line: continue + if 'description = models.TextField(blank=True' in line: continue + if 'created_at = models.DateTimeField(auto_now_add=True' in line: continue + if 'updated_at = models.DateTimeField(auto_now=True' in line: continue + + if in_category: + if 'code = models.CharField(max_length=10' in line: continue + if 'name = models.CharField(max_length=100' in line: continue + if 'description = models.TextField(blank=True' in line: continue + + if in_vendor: + if 'created_at = models.DateTimeField(auto_now_add=True)' in line: continue + if 'updated_at = models.DateTimeField(auto_now=True)' in line: continue + + if in_projectphase: + if 'created_at = models.DateTimeField(auto_now_add=True)' in line: continue + if 'updated_at = models.DateTimeField(auto_now=True)' in line: continue + + if in_expense: + if 'created_at = models.DateTimeField(auto_now_add=True)' in line: continue + if 'updated_at = models.DateTimeField(auto_now=True)' in line: continue + if ' def save(self, *args, **kwargs):' in line: + encap = """ + 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() +""" + new_lines.append(encap + "\n") + + new_lines.append(line) + +proxy = """ +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" +""" +new_lines.append(proxy) + +with open('expenses/models.py', 'w', encoding='utf-8') as f: + f.writelines(new_lines) diff --git a/seed_categories.py b/seed_categories.py index 82b1d5d..be01da0 100644 --- a/seed_categories.py +++ b/seed_categories.py @@ -10,15 +10,12 @@ django.setup() from expenses.models import Category def run_seed(): - print("Clearing existing categories to ensure clean state...") - Category.objects.all().delete() + print("Seeding professional CBS categories and Chart of Accounts...") - print("Seeding professional CBS categories...") - - # Define the structure + # Define the complete EFINTEL CBS structure cbs_structure = { "1000": { - "name": "Recursos Humanos y Especialidades", + "name": "Recursos Humanos y Especialidades I+D+i", "children": { "1100": "Personal Staff Core (Fijo / Sueldos Base)", "1201": "Electricidad de Potencia e Infraestructura", @@ -31,11 +28,12 @@ def run_seed(): } }, "2000": { - "name": "Materiales e Insumos", + "name": "Materiales, Insumos e Infraestructura de Trabajo", "children": { "2100": "Componentes Electrónicos Prototipado", "2200": "Equipamiento Industrial de Campo", "2300": "Materiales Eléctricos y de Montaje", + "2301": "Arriendo de Oficinas y Espacios de Trabajo", "2400": "Insumos de Oficina, Aseo, Cafetería y Papelería" } }, @@ -54,12 +52,22 @@ def run_seed(): } }, "4000": { - "name": "Software y Herramientas Digitales", + "name": "Software, Herramientas Digitales y Formación", "children": { "4100": "Licencias de Ingeniería & Diseño (Altium/Unity/Unreal)", - "4200": "Infraestructura y Servidores Cloud (AWS/Azure)", + "4200": "Infraestructura y Servidores Cloud Tradicionales", + "4201": "Cursos, Capacitación y Certificaciones Técnicas para Especialistas", "4300": "Herramientas de Gestión de Proyectos / Software General" } + }, + "7000": { + "name": "Software, Datos, Inteligencia Artificial y Cloud", + "children": { + "7100": "Licencias IA y Modelos Propietarios", + "7200": "Cloud & Computación GPU de Alto Rendimiento", + "7300": "Consumo de APIs LLM (OpenAI, Anthropic, Gemini)", + "7400": "MLOps, Pipelines de Datos y Despliegue de Modelos" + } } } @@ -69,14 +77,21 @@ def run_seed(): code=p_code, defaults={'name': p_data['name'], 'description': 'Partida Principal (Macro-categoría)'} ) - print(f"Created Parent: {parent_cat.code} - {parent_cat.name}") + if parent_cat.name != p_data['name']: + parent_cat.name = p_data['name'] + parent_cat.save() + print(f"Parent: {parent_cat.code} - {parent_cat.name}") for c_code, c_name in p_data['children'].items(): child_cat, _ = Category.objects.get_or_create( code=c_code, defaults={'name': c_name, 'parent': parent_cat} ) - print(f" Created Child: {child_cat.code} - {child_cat.name}") + if child_cat.name != c_name or child_cat.parent != parent_cat: + child_cat.name = c_name + child_cat.parent = parent_cat + child_cat.save() + print(f" Child: {child_cat.code} - {child_cat.name}") print("✅ CBS Seeding completed successfully.") diff --git a/static/css/style.css b/static/css/style.css index 8b18b6f..33c817c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -8,27 +8,28 @@ --efintel-blue: #0078A8; /* Primary brand color, main navigation, interactive elements */ --efintel-turquoise: #1EB8B0; /* R&D&I innovation accent, positive indicators, progress */ --efintel-petroleum: #005B73; /* System modules, secondary navigation, tech headers */ - --efintel-dark: #4B4B4B; /* Titles, primary headers, heavy contrast text */ - --efintel-gray: #7A7F83; /* Secondary body text, captions, subtitles */ - --efintel-light: #F2F4F5; /* Backgrounds, secondary card surfaces */ + --efintel-dark: #2A343D; /* Titles, primary headers, heavy contrast text */ + --efintel-gray: #64748B; /* Secondary body text, captions, subtitles */ + --efintel-light: #F8FAFC; /* Backgrounds, secondary card surfaces */ --efintel-white: #FFFFFF; /* Primary surfaces, cards, modals */ - --efintel-border: #DDE3E7; /* Clean subtle borders */ - --efintel-border-hover: #B8C4CE; /* Hover state borders */ + --efintel-border: #E2E8F0; /* Clean subtle borders */ + --efintel-border-hover: #CBD5E1; /* Hover state borders */ /* State & Alert Palette */ - --efintel-success: #2E8B57; /* Positive state, compliance */ - --efintel-warning: #E3A008; /* Warnings, pending review */ - --efintel-danger: #D64545; /* Errors, critical risk */ + --efintel-success: #10B981; /* Positive state, compliance */ + --efintel-warning: #F59E0B; /* Warnings, pending review */ + --efintel-danger: #EF4444; /* Errors, critical risk */ - /* Surface Shadows & Elevations (Extremely subtle, corporate) */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08); + /* Modern Glassmorphic & Elevation Shadows */ + --shadow-sm: 0 2px 8px rgba(15, 23, 42, 0.04), 0 1px 2px rgba(15, 23, 42, 0.02); + --shadow-md: 0 8px 24px -4px rgba(15, 23, 42, 0.08), 0 4px 8px -2px rgba(15, 23, 42, 0.04); + --shadow-lg: 0 20px 32px -8px rgba(15, 23, 42, 0.12), 0 8px 16px -4px rgba(15, 23, 42, 0.06); - /* Radius Tokens */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; + /* Modern Rounded Radius Tokens */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 24px; + --radius-xl: 32px; /* Typography Tokens */ --font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -180,6 +181,38 @@ body { MAIN CONTENT & LAYOUT STRUCTURE ========================================================================== */ +/* Keyframes Animation Suite */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes modalZoomIn { + from { + opacity: 0; + transform: scale(0.94); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulseGlow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(30, 184, 176, 0.4); + } + 50% { + box-shadow: 0 0 0 8px rgba(30, 184, 176, 0); + } +} + .main-container { max-width: 1440px; margin: 0 auto; @@ -192,6 +225,7 @@ body { .page-view.active { display: block; + animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } .view-header { @@ -222,22 +256,29 @@ body { ========================================================================== */ .btn-primary { - background-color: var(--efintel-blue); + background: linear-gradient(135deg, var(--efintel-blue) 0%, var(--efintel-petroleum) 100%); color: var(--efintel-white); border: none; border-radius: var(--radius-sm); - padding: 0.6rem 1.1rem; + padding: 0.65rem 1.2rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; - transition: background-color 0.2s ease, transform 0.1s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(0, 120, 168, 0.25); } .btn-primary:hover { - background-color: #00648d; + transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(0, 120, 168, 0.35); + filter: brightness(1.08); +} + +.btn-primary:active { + transform: translateY(0) scale(0.98); } .btn-secondary { @@ -245,38 +286,41 @@ body { color: var(--efintel-dark); border: 1px solid var(--efintel-border); border-radius: var(--radius-sm); - padding: 0.6rem 1.1rem; + padding: 0.65rem 1.2rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; - transition: all 0.2s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); } .btn-secondary:hover { - border-color: var(--efintel-border-hover); - background-color: var(--efintel-light); + border-color: var(--efintel-blue); + background-color: rgba(0, 120, 168, 0.04); + transform: translateY(-2px); } .btn-danger { background-color: var(--efintel-white); color: var(--efintel-danger); - border: 1px solid var(--efintel-danger); + border: 1px solid rgba(239, 68, 68, 0.3); border-radius: var(--radius-sm); - padding: 0.6rem 1.1rem; + padding: 0.65rem 1.2rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; - transition: all 0.2s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); } .btn-danger:hover { - background-color: #fdf2f2; + background-color: rgba(239, 68, 68, 0.08); + border-color: var(--efintel-danger); + transform: translateY(-2px); } /* ========================================================================== @@ -308,6 +352,12 @@ body { display: flex; flex-direction: column; justify-content: space-between; + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.kpi-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md), 0 12px 28px -6px rgba(0, 120, 168, 0.12); } /* Accent Lateral Bar */ @@ -351,6 +401,11 @@ body { display: flex; align-items: center; justify-content: center; + transition: transform 0.25s ease; +} + +.kpi-card:hover .kpi-icon { + transform: scale(1.1); } .kpi-value { @@ -390,35 +445,45 @@ body { background-color: var(--efintel-danger); } -/* Badges */ +/* Badges with modern rounded pill design */ .badge { display: inline-flex; align-items: center; - gap: 0.3rem; - padding: 0.25rem 0.6rem; - border-radius: 4px; + gap: 0.35rem; + padding: 0.3rem 0.75rem; + border-radius: 9999px; font-size: 0.75rem; - font-weight: 600; + font-weight: 700; + letter-spacing: 0.01em; + transition: transform 0.2s ease; +} + +.badge:hover { + transform: scale(1.04); } .badge-emerald { - background-color: rgba(46, 139, 87, 0.12); - color: var(--efintel-success); + background-color: rgba(16, 185, 129, 0.12); + color: #047857; + border: 1px solid rgba(16, 185, 129, 0.25); } .badge-amber { - background-color: rgba(227, 160, 8, 0.12); - color: var(--efintel-warning); + background-color: rgba(245, 158, 11, 0.12); + color: #B45309; + border: 1px solid rgba(245, 158, 11, 0.25); } .badge-red { - background-color: rgba(214, 69, 69, 0.12); - color: var(--efintel-danger); + background-color: rgba(239, 68, 68, 0.12); + color: #B91C1C; + border: 1px solid rgba(239, 68, 68, 0.25); } .badge-blue { background-color: rgba(0, 120, 168, 0.12); color: var(--efintel-blue); + border: 1px solid rgba(0, 120, 168, 0.25); } /* ========================================================================== @@ -655,34 +720,133 @@ body { padding: 1.5rem; } -.form-group { - margin-bottom: 1.25rem; +/* Modern Form Styles */ +.modern-form { + display: flex; + flex-direction: column; + gap: 1.5rem; } -.form-group label { - display: block; - font-size: 0.8rem; +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.25rem 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label, +form label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; font-weight: 600; color: var(--efintel-dark); - margin-bottom: 0.4rem; + letter-spacing: -0.01em; + margin-bottom: 0.2rem; } -.form-input { - width: 100%; - padding: 0.65rem 0.85rem; - border: 1px solid var(--efintel-border); +.form-help { + font-size: 0.75rem; + color: var(--efintel-gray); + margin-top: 0.2rem; +} + +.form-error { + font-size: 0.75rem; + color: var(--efintel-red); + margin-top: 0.2rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--efintel-border); +} + +.btn-secondary { + padding: 0.65rem 1.2rem; + font-size: 0.88rem; + font-weight: 600; border-radius: var(--radius-sm); - font-size: 0.85rem; + background: var(--efintel-gray); + color: white; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; + border: none; + cursor: pointer; +} + +.btn-secondary:hover { + background: #64748b; + color: white; +} + +form p { + margin-bottom: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-control, +.form-input, +form input:not([type="checkbox"]):not([type="radio"]), +form select, +form textarea { + width: 100%; + padding: 0.7rem 1rem; + border: 1.5px solid var(--efintel-border); + border-radius: var(--radius-sm); + font-size: 0.88rem; font-family: var(--font-main); color: var(--efintel-dark); background-color: var(--efintel-white); - transition: border-color 0.2s ease; + transition: border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.25s ease; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03); } -.form-input:focus { +.form-control:hover, +.form-input:hover, +form input:not([type="checkbox"]):not([type="radio"]):hover, +form select:hover, +form textarea:hover { + border-color: var(--efintel-blue); + background-color: #FFFFFF; +} + +.form-control:focus, +.form-input:focus, +form input:not([type="checkbox"]):not([type="radio"]):focus, +form select:focus, +form textarea:focus { outline: none; border-color: var(--efintel-blue); - box-shadow: 0 0 0 3px rgba(0, 120, 168, 0.12); + box-shadow: 0 0 0 4px rgba(0, 120, 168, 0.16); + background-color: #FFFFFF; +} + +form input[type="checkbox"], +form input[type="radio"] { + width: 1.15rem; + height: 1.15rem; + accent-color: var(--efintel-blue); + cursor: pointer; } /* Evidence Slot Grid */ diff --git a/talent/__init__.py b/talent/__init__.py new file mode 100644 index 0000000..9835196 --- /dev/null +++ b/talent/__init__.py @@ -0,0 +1 @@ +# Talent app diff --git a/talent/admin.py b/talent/admin.py new file mode 100644 index 0000000..744bd9c --- /dev/null +++ b/talent/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from .models import SpecialistCandidate, EvaluationRubric7D + +@admin.register(SpecialistCandidate) +class SpecialistCandidateAdmin(admin.ModelAdmin): + list_display = ('rut', 'first_name', 'last_name', 'category_code', 'specialty_title', 'seniority', 'trl_level', 'status', 'availability') + search_fields = ('rut', 'first_name', 'last_name', 'specialty_title') + list_filter = ('category_code', 'seniority', 'trl_level', 'status', 'availability') + +@admin.register(EvaluationRubric7D) +class EvaluationRubric7DAdmin(admin.ModelAdmin): + list_display = ('candidate', 'evaluator', 'evaluation_date', 'total_weighted_score', 'is_knockout', 'result_status', 'valid_until') + search_fields = ('candidate__rut', 'candidate__first_name', 'candidate__last_name') + list_filter = ('result_status', 'is_knockout', 'evaluation_date') diff --git a/talent/apps.py b/talent/apps.py new file mode 100644 index 0000000..6c5956a --- /dev/null +++ b/talent/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class TalentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'talent' + verbose_name = 'Cartera de Especialistas y Talento I+D+i' diff --git a/talent/forms.py b/talent/forms.py new file mode 100644 index 0000000..2450c32 --- /dev/null +++ b/talent/forms.py @@ -0,0 +1,66 @@ +from django import forms +from .models import SpecialistCandidate, EvaluationRubric7D +from decimal import Decimal + +class SpecialistCandidateForm(forms.ModelForm): + hourly_rate_clp = forms.DecimalField( + label="Tarifa por Hora (CLP)", + required=False, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000'}) + ) + monthly_salary_target_clp = forms.DecimalField( + label="Pretensión de Renta Mensual (CLP)", + required=False, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '10000'}) + ) + + class Meta: + model = SpecialistCandidate + fields = [ + 'rut', 'first_name', 'last_name', 'email', 'phone', 'city', + 'category_code', 'specialty_title', 'seniority', 'trl_level', + 'portfolio_url', 'github_url', 'linkedin_url', + 'hourly_rate_clp', 'monthly_salary_target_clp', 'availability', + 'status', 'summary' + ] + widgets = { + 'rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 15.678.910-2', 'pattern': r'^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$', 'title': 'Formato válido: XX.XXX.XXX-X', 'required': True}), + 'first_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}), + 'last_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}), + 'email': forms.EmailInput(attrs={'class': 'form-control', 'required': True}), + 'phone': forms.TextInput(attrs={'class': 'form-control', 'pattern': r'^\+?[0-9]{8,15}$', 'title': 'Número de teléfono válido (ej: +56912345678)'}), + 'city': forms.HiddenInput(), + 'category_code': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'specialty_title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Ingeniero en Control & Gemelos Digitales', 'required': True}), + 'seniority': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'trl_level': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'portfolio_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://...'}), + 'github_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://github.com/...'}), + 'linkedin_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://linkedin.com/in/...'}), + 'availability': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'status': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'summary': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Perfil técnico, certificaciones y experiencia en proyectos I+D+i...'}), + } + + +class EvaluationRubric7DForm(forms.ModelForm): + class Meta: + model = EvaluationRubric7D + fields = [ + 'candidate', 'evaluation_date', + 'dim_case_study', 'dim_portfolio', 'dim_tech_specific', 'dim_cardinal_skills', + 'dim_experience', 'dim_principles_safety', 'dim_innovation_rndi', + 'feedback' + ] + widgets = { + 'candidate': forms.Select(attrs={'class': 'form-control', 'id': 'id_candidate', 'required': True}), + 'evaluation_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': True}), + 'dim_case_study': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_1', 'required': True}), + 'dim_portfolio': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_2', 'required': True}), + 'dim_tech_specific': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_3', 'required': True}), + 'dim_cardinal_skills': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_4', 'required': True}), + 'dim_experience': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_5', 'required': True}), + 'dim_principles_safety': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_6', 'required': True}), + 'dim_innovation_rndi': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_7', 'required': True}), + 'feedback': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Comentarios detallados de la evaluación...'}), + } diff --git a/talent/migrations/0001_initial.py b/talent/migrations/0001_initial.py new file mode 100644 index 0000000..175bf8a --- /dev/null +++ b/talent/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 6.1 on 2026-09-07 02:50 + +import datetime +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SpecialistCandidate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rut', models.CharField(max_length=20, unique=True, verbose_name='RUT Especialista')), + ('first_name', models.CharField(max_length=100, verbose_name='Nombres')), + ('last_name', models.CharField(max_length=100, verbose_name='Apellidos')), + ('email', models.EmailField(max_length=254, verbose_name='Correo Electrónico')), + ('phone', models.CharField(blank=True, max_length=30, verbose_name='Teléfono / WhatsApp')), + ('city', models.CharField(default='Santiago', max_length=100, verbose_name='Ciudad / Región de Residencia')), + ('category_code', models.CharField(choices=[('1', '1. Electricidad de Potencia e Infraestructura'), ('2', '2. Automatización y Control Industrial'), ('3', '3. Domótica e Inmótica (BMS & Climatización)'), ('4', '4. Electrónica y Hardware (PCB / Firmware)'), ('5', '5. Redes, Conectividad e Informática (TI & Ciberseguridad)'), ('6', '6. Tecnologías Inmersivas y Gemelos Digitales (VR/AR/LiDAR)'), ('7', '7. Software, Datos, IA y Cloud')], max_length=10, verbose_name='Partida / Categoría Principal (1 a 7)')), + ('specialty_title', models.CharField(max_length=150, verbose_name='Título / Especialidad Técnica Específica')), + ('seniority', models.CharField(choices=[('JUNIOR', 'Junior (1-3 años)'), ('SEMI_SENIOR', 'Semi-Senior (3-5 años)'), ('SENIOR', 'Senior (5-8 años)'), ('LEAD_SPECIALIST', 'Lead / Principal Specialist (+8 años)')], default='SEMI_SENIOR', max_length=30, verbose_name='Nivel de Seniority')), + ('trl_level', models.IntegerField(choices=[(1, 'TRL 1 - Principios básicos observados'), (2, 'TRL 2 - Concepto tecnológico formulado'), (3, 'TRL 3 - Prueba de concepto experimental'), (4, 'TRL 4 - Validación en laboratorio'), (5, 'TRL 5 - Validación en entorno relevante'), (6, 'TRL 6 - Demostración en entorno relevante'), (7, 'TRL 7 - Demostración de prototipo en entorno operacional'), (8, 'TRL 8 - Sistema completo y calificado'), (9, 'TRL 9 - Sistema probado con éxito en entorno real/industrial')], default=6, verbose_name='Nivel de Madurez Tecnológica Dominado (TRL 1-9)')), + ('portfolio_url', models.URLField(blank=True, verbose_name='URL Portafolio / Web Personal')), + ('github_url', models.URLField(blank=True, verbose_name='URL GitHub / Repositorio')), + ('linkedin_url', models.URLField(blank=True, verbose_name='URL Perfil LinkedIn')), + ('hourly_rate_clp', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Tarifa por Hora Pactada (CLP)')), + ('monthly_salary_target_clp', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Pretensión de Renta Mensual (CLP)')), + ('availability', models.CharField(choices=[('INMEDIATA', 'Disponibilidad Inmediata Full-Time'), ('DOS_SEMANAS', '2 Semanas (Plazo Aviso)'), ('UN_MES', '1 Mes'), ('FREELANCE_PROYECTO', 'Por Horas / Proyectos Específicos')], default='INMEDIATA', max_length=30, verbose_name='Disponibilidad')), + ('status', models.CharField(choices=[('POSTULANTE', 'Postulante Inicial'), ('EN_EVALUACION', 'En Proceso de Evaluación 7D'), ('APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas'), ('EN_RESERVA', 'En Reserva / Banco de Talento'), ('DESCARTADO', 'Descartado / No Apto')], default='POSTULANTE', max_length=30, verbose_name='Estado en Cartera')), + ('summary', models.TextField(blank=True, verbose_name='Resumen de Experiencia y Competencias')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Especialista / Candidato', + 'verbose_name_plural': 'Cartera de Especialistas', + 'ordering': ['category_code', 'last_name', 'first_name'], + }, + ), + migrations.CreateModel( + name='EvaluationRubric7D', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('evaluation_date', models.DateField(default=datetime.date.today, verbose_name='Fecha de Evaluación')), + ('dim_case_study', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='1. Caso Práctico / Prueba Técnica (25% - Knockout < 70)')), + ('dim_portfolio', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='2. Portafolio y Proyectos Previos (15%)')), + ('dim_tech_specific', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='3. Competencias Técnicas Específicas (15%)')), + ('dim_cardinal_skills', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='4. Competencias Cardinales / Blandas (15%)')), + ('dim_experience', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='5. Experiencia en Terreno / Industria (10%)')), + ('dim_principles_safety', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='6. Alineación con Principios y Seguridad EFINTEL (10%)')), + ('dim_innovation_rndi', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='7. Capacidad de Innovación e I+D+i (10%)')), + ('is_knockout', models.BooleanField(default=False, verbose_name='Descarte por Criterio Knockout (Prueba < 70)')), + ('total_weighted_score', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Puntaje Ponderado Total (0-100)')), + ('result_status', models.CharField(choices=[('POSTULANTE', 'Postulante Inicial'), ('EN_EVALUACION', 'En Proceso de Evaluación 7D'), ('APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas'), ('EN_RESERVA', 'En Reserva / Banco de Talento'), ('DESCARTADO', 'Descartado / No Apto')], max_length=30, verbose_name='Dictamen Final')), + ('valid_until', models.DateField(verbose_name='Vigencia de la Calificación (12 Meses)')), + ('feedback', models.TextField(blank=True, verbose_name='Observaciones, Fortalezas y Brechas Detectadas')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('evaluator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Evaluador Líder')), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='talent.specialistcandidate', verbose_name='Especialista Candidato')), + ], + options={ + 'verbose_name': 'Evaluación 7D de Especialista', + 'verbose_name_plural': 'Evaluaciones Rúbrica 7D', + 'ordering': ['-evaluation_date', '-created_at'], + }, + ), + ] diff --git a/talent/migrations/__init__.py b/talent/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/talent/models.py b/talent/models.py new file mode 100644 index 0000000..1aa3dd6 --- /dev/null +++ b/talent/models.py @@ -0,0 +1,145 @@ +from django.db import models +from django.contrib.auth.models import User +from decimal import Decimal +import uuid +from datetime import date, timedelta + +class SpecialistCategoryEnum(models.TextChoices): + CAT_1_ELECTRICIDAD = '1', '1. Electricidad de Potencia e Infraestructura' + CAT_2_AUTOMATIZACION = '2', '2. Automatización y Control Industrial' + CAT_3_DOMOTICA = '3', '3. Domótica e Inmótica (BMS & Climatización)' + CAT_4_ELECTRONICA = '4', '4. Electrónica y Hardware (PCB / Firmware)' + CAT_5_REDES = '5', '5. Redes, Conectividad e Informática (TI & Ciberseguridad)' + CAT_6_INMERSIVAS = '6', '6. Tecnologías Inmersivas y Gemelos Digitales (VR/AR/LiDAR)' + CAT_7_SOFTWARE_DATOS_IA_CLOUD = '7', '7. Software, Datos, IA y Cloud' + +class SeniorityEnum(models.TextChoices): + JUNIOR = 'JUNIOR', 'Junior (1-3 años)' + SEMI_SENIOR = 'SEMI_SENIOR', 'Semi-Senior (3-5 años)' + SENIOR = 'SENIOR', 'Senior (5-8 años)' + LEAD_SPECIALIST = 'LEAD_SPECIALIST', 'Lead / Principal Specialist (+8 años)' + +class TRLChoiceEnum(models.IntegerChoices): + TRL_1 = 1, 'TRL 1 - Principios básicos observados' + TRL_2 = 2, 'TRL 2 - Concepto tecnológico formulado' + TRL_3 = 3, 'TRL 3 - Prueba de concepto experimental' + TRL_4 = 4, 'TRL 4 - Validación en laboratorio' + TRL_5 = 5, 'TRL 5 - Validación en entorno relevante' + TRL_6 = 6, 'TRL 6 - Demostración en entorno relevante' + TRL_7 = 7, 'TRL 7 - Demostración de prototipo en entorno operacional' + TRL_8 = 8, 'TRL 8 - Sistema completo y calificado' + TRL_9 = 9, 'TRL 9 - Sistema probado con éxito en entorno real/industrial' + +class CandidateStatusEnum(models.TextChoices): + POSTULANTE = 'POSTULANTE', 'Postulante Inicial' + EN_EVALUACION = 'EN_EVALUACION', 'En Proceso de Evaluación 7D' + APROBADO_CARTERA = 'APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas' + EN_RESERVA = 'EN_RESERVA', 'En Reserva / Banco de Talento' + DESCARTADO = 'DESCARTADO', 'Descartado / No Apto' + +class AvailabilityEnum(models.TextChoices): + INMEDIATA = 'INMEDIATA', 'Disponibilidad Inmediata Full-Time' + DOS_SEMANAS = 'DOS_SEMANAS', '2 Semanas (Plazo Aviso)' + UN_MES = 'UN_MES', '1 Mes' + FREELANCE_PROYECTO = 'FREELANCE_PROYECTO', 'Por Horas / Proyectos Específicos' + + +class SpecialistCandidate(models.Model): + """ + Ficha de Especialista y Postulante para Cartera I+D+i EFINTEL. + """ + rut = models.CharField(max_length=20, unique=True, verbose_name="RUT Especialista") + first_name = models.CharField(max_length=100, verbose_name="Nombres") + last_name = models.CharField(max_length=100, verbose_name="Apellidos") + email = models.EmailField(verbose_name="Correo Electrónico") + phone = models.CharField(max_length=30, blank=True, verbose_name="Teléfono / WhatsApp") + city = models.CharField(max_length=100, default="Santiago", verbose_name="Ciudad / Región de Residencia") + + # Categorización Técnica + category_code = models.CharField(max_length=10, choices=SpecialistCategoryEnum.choices, verbose_name="Partida / Categoría Principal (1 a 7)") + specialty_title = models.CharField(max_length=150, verbose_name="Título / Especialidad Técnica Específica") + seniority = models.CharField(max_length=30, choices=SeniorityEnum.choices, default=SeniorityEnum.SEMI_SENIOR, verbose_name="Nivel de Seniority") + trl_level = models.IntegerField(choices=TRLChoiceEnum.choices, default=TRLChoiceEnum.TRL_6, verbose_name="Nivel de Madurez Tecnológica Dominado (TRL 1-9)") + + # Portafolio y Enlaces + portfolio_url = models.URLField(blank=True, verbose_name="URL Portafolio / Web Personal") + github_url = models.URLField(blank=True, verbose_name="URL GitHub / Repositorio") + linkedin_url = models.URLField(blank=True, verbose_name="URL Perfil LinkedIn") + + # Condiciones + hourly_rate_clp = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Tarifa por Hora Pactada (CLP)") + monthly_salary_target_clp = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Pretensión de Renta Mensual (CLP)") + availability = models.CharField(max_length=30, choices=AvailabilityEnum.choices, default=AvailabilityEnum.INMEDIATA, verbose_name="Disponibilidad") + + status = models.CharField(max_length=30, choices=CandidateStatusEnum.choices, default=CandidateStatusEnum.POSTULANTE, verbose_name="Estado en Cartera") + summary = models.TextField(blank=True, verbose_name="Resumen de Experiencia y Competencias") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.rut} - {self.first_name} {self.last_name} ({self.get_category_code_display()})" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def latest_evaluation(self): + return self.evaluations.order_by('-evaluation_date', '-created_at').first() + + class Meta: + verbose_name = "Especialista / Candidato" + verbose_name_plural = "Cartera de Especialistas" + ordering = ['category_code', 'last_name', 'first_name'] + + +class EvaluationRubric7D(models.Model): + """ + Rúbrica de Evaluación Holística en 7 Dimensiones Ponderadas. + Ponderaciones: + 1. Caso Práctico / Prueba Técnica: 25% (CRITERIO KNOCKOUT: < 70 Descarte Automático) + 2. Portafolio y Proyectos Previos: 15% + 3. Competencias Técnicas Específicas: 15% + 4. Competencias Cardinales / Blandas: 15% + 5. Experiencia Comprobable en Terreno: 10% + 6. Alineación con Principios y Seguridad: 10% + 7. Capacidad de Innovación e I+D+i: 10% + Vigencia: 12 meses. + """ + candidate = models.ForeignKey(SpecialistCandidate, on_delete=models.CASCADE, related_name='evaluations', verbose_name="Especialista Candidato") + evaluator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Evaluador Líder") + evaluation_date = models.DateField(default=date.today, verbose_name="Fecha de Evaluación") + + # 7 Dimensiones (Escala 0 - 100) + dim_case_study = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="1. Caso Práctico / Prueba Técnica (25% - Knockout < 70)") + dim_portfolio = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="2. Portafolio y Proyectos Previos (15%)") + dim_tech_specific = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="3. Competencias Técnicas Específicas (15%)") + dim_cardinal_skills = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="4. Competencias Cardinales / Blandas (15%)") + dim_experience = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="5. Experiencia en Terreno / Industria (10%)") + dim_principles_safety = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="6. Alineación con Principios y Seguridad EFINTEL (10%)") + dim_innovation_rndi = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="7. Capacidad de Innovación e I+D+i (10%)") + + # Flags y Resultado + is_knockout = models.BooleanField(default=False, verbose_name="Descarte por Criterio Knockout (Prueba < 70)") + total_weighted_score = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="Puntaje Ponderado Total (0-100)") + result_status = models.CharField(max_length=30, choices=CandidateStatusEnum.choices, verbose_name="Dictamen Final") + + valid_until = models.DateField(verbose_name="Vigencia de la Calificación (12 Meses)") + feedback = models.TextField(blank=True, verbose_name="Observaciones, Fortalezas y Brechas Detectadas") + + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + # Auto-set validity to 12 months from evaluation date if not set + if not self.valid_until and self.evaluation_date: + self.valid_until = self.evaluation_date + timedelta(days=365) + super().save(*args, **kwargs) + + def __str__(self): + return f"Evaluación 7D {self.candidate.full_name} - Score: {self.total_weighted_score}/100 ({self.get_result_status_display()})" + + class Meta: + verbose_name = "Evaluación 7D de Especialista" + verbose_name_plural = "Evaluaciones Rúbrica 7D" + ordering = ['-evaluation_date', '-created_at'] diff --git a/talent/services/__init__.py b/talent/services/__init__.py new file mode 100644 index 0000000..7f95689 --- /dev/null +++ b/talent/services/__init__.py @@ -0,0 +1,3 @@ +from .rubric import TalentScoringEngine + +__all__ = ['TalentScoringEngine'] diff --git a/talent/services/rubric.py b/talent/services/rubric.py new file mode 100644 index 0000000..9338beb --- /dev/null +++ b/talent/services/rubric.py @@ -0,0 +1,93 @@ +""" +7-Dimension Scoring Engine with Knockout Criterion for EFINTEL Talent Pool. +""" + +from decimal import Decimal, ROUND_HALF_UP +from datetime import date, timedelta +from typing import Dict, Any + +class TalentScoringEngine: + # 7-Dimension Official Weighting + WEIGHT_CASE_STUDY = Decimal('0.25') # 25% (Knockout if < 70) + WEIGHT_PORTFOLIO = Decimal('0.15') # 15% + WEIGHT_TECH_SPECIFIC = Decimal('0.15') # 15% + WEIGHT_CARDINAL_SKILLS = Decimal('0.15') # 15% + WEIGHT_EXPERIENCE = Decimal('0.10') # 10% + WEIGHT_PRINCIPLES = Decimal('0.10') # 10% + WEIGHT_INNOVATION = Decimal('0.10') # 10% + + KNOCKOUT_THRESHOLD = Decimal('70.0') + APPROVAL_THRESHOLD = Decimal('80.0') + RESERVE_THRESHOLD = Decimal('65.0') + VALIDITY_DAYS = 365 + + @classmethod + def evaluate( + cls, + dim_case_study: Decimal, + dim_portfolio: Decimal, + dim_tech_specific: Decimal, + dim_cardinal_skills: Decimal, + dim_experience: Decimal, + dim_principles_safety: Decimal, + dim_innovation_rndi: Decimal, + evaluation_date: date = None + ) -> Dict[str, Any]: + """ + Calculates weighted 7D score, evaluates Knockout condition, determines status and validity. + """ + d1 = Decimal(str(dim_case_study or 0)) + d2 = Decimal(str(dim_portfolio or 0)) + d3 = Decimal(str(dim_tech_specific or 0)) + d4 = Decimal(str(dim_cardinal_skills or 0)) + d5 = Decimal(str(dim_experience or 0)) + d6 = Decimal(str(dim_principles_safety or 0)) + d7 = Decimal(str(dim_innovation_rndi or 0)) + + # Check Knockout condition on Dimension 1 (Caso Práctico / Prueba Técnica) + is_knockout = d1 < cls.KNOCKOUT_THRESHOLD + + # Calculate weighted total + weighted_score = ( + (d1 * cls.WEIGHT_CASE_STUDY) + + (d2 * cls.WEIGHT_PORTFOLIO) + + (d3 * cls.WEIGHT_TECH_SPECIFIC) + + (d4 * cls.WEIGHT_CARDINAL_SKILLS) + + (d5 * cls.WEIGHT_EXPERIENCE) + + (d6 * cls.WEIGHT_PRINCIPLES) + + (d7 * cls.WEIGHT_INNOVATION) + ).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + # Determine Final Result Status + if is_knockout: + result_status = 'DESCARTADO' + verdict_message = f"Descartado por Criterio Knockout: Caso Práctico ({d1}/100) inferior al mínimo aprobatorio de {cls.KNOCKOUT_THRESHOLD} puntos." + elif weighted_score >= cls.APPROVAL_THRESHOLD: + result_status = 'APROBADO_CARTERA' + verdict_message = f"¡Aprobado en Cartera de Especialistas! Puntaje de {weighted_score}/100 superior al umbral de excelencia ({cls.APPROVAL_THRESHOLD})." + elif weighted_score >= cls.RESERVE_THRESHOLD: + result_status = 'EN_RESERVA' + verdict_message = f"Calificado en Banco de Reserva ({weighted_score}/100). Cumple estándares operacionales para proyectos secundarios o convocatorias de contingencia." + else: + result_status = 'DESCARTADO' + verdict_message = f"Descartado: Puntaje ponderado ({weighted_score}/100) no alcanza el umbral mínimo de corte ({cls.RESERVE_THRESHOLD})." + + eval_date = evaluation_date or date.today() + valid_until = eval_date + timedelta(days=cls.VALIDITY_DAYS) + + return { + 'weighted_score': weighted_score, + 'is_knockout': is_knockout, + 'result_status': result_status, + 'verdict_message': verdict_message, + 'valid_until': valid_until, + 'breakdown': { + 'case_study_weighted': (d1 * cls.WEIGHT_CASE_STUDY).quantize(Decimal('0.01')), + 'portfolio_weighted': (d2 * cls.WEIGHT_PORTFOLIO).quantize(Decimal('0.01')), + 'tech_specific_weighted': (d3 * cls.WEIGHT_TECH_SPECIFIC).quantize(Decimal('0.01')), + 'cardinal_skills_weighted': (d4 * cls.WEIGHT_CARDINAL_SKILLS).quantize(Decimal('0.01')), + 'experience_weighted': (d5 * cls.WEIGHT_EXPERIENCE).quantize(Decimal('0.01')), + 'principles_weighted': (d6 * cls.WEIGHT_PRINCIPLES).quantize(Decimal('0.01')), + 'innovation_weighted': (d7 * cls.WEIGHT_INNOVATION).quantize(Decimal('0.01')), + } + } diff --git a/talent/urls.py b/talent/urls.py new file mode 100644 index 0000000..c2ad795 --- /dev/null +++ b/talent/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = 'talent' + +urlpatterns = [ + path('', views.candidate_list, name='talent_index'), + path('especialistas/', views.candidate_list, name='candidate_list'), + path('especialistas/nuevo/', views.candidate_create, name='candidate_create'), + path('especialistas//', views.candidate_detail, name='candidate_detail'), + path('especialistas//editar/', views.candidate_edit, name='candidate_edit'), + + # Evaluaciones 7D + path('evaluaciones/nueva/', views.evaluation_create, name='evaluation_create'), + path('especialistas//evaluar/', views.evaluation_create, name='evaluation_for_candidate'), + path('evaluaciones//', views.evaluation_detail, name='evaluation_detail'), +] diff --git a/talent/views.py b/talent/views.py new file mode 100644 index 0000000..c64afe6 --- /dev/null +++ b/talent/views.py @@ -0,0 +1,162 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.http import JsonResponse +from django.db.models import Q, Count, Avg +from decimal import Decimal +from datetime import date +import json + +from .models import SpecialistCandidate, EvaluationRubric7D, SpecialistCategoryEnum, SeniorityEnum, CandidateStatusEnum +from .forms import SpecialistCandidateForm, EvaluationRubric7DForm +from .services.rubric import TalentScoringEngine +from expenses.chile_regions import REGIONS_COMMUNES + +@login_required +def candidate_list(request): + candidates = SpecialistCandidate.objects.select_related().all().order_by('-created_at') + + # Filter params + category = request.GET.get('category') + seniority = request.GET.get('seniority') + trl = request.GET.get('trl') + status = request.GET.get('status') + query = request.GET.get('q') + + if category: + candidates = candidates.filter(category_code=category) + if seniority: + candidates = candidates.filter(seniority=seniority) + if trl: + candidates = candidates.filter(trl_level=int(trl)) + if status: + candidates = candidates.filter(status=status) + if query: + candidates = candidates.filter( + Q(first_name__icontains=query) | + Q(last_name__icontains=query) | + Q(rut__icontains=query) | + Q(specialty_title__icontains=query) | + Q(summary__icontains=query) + ) + + # Statistics + stats = { + 'total': SpecialistCandidate.objects.count(), + 'approved': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.APROBADO_CARTERA).count(), + 'reserve': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.EN_RESERVA).count(), + 'evaluating': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.EN_EVALUACION).count(), + } + + return render(request, 'talent/candidate_list.html', { + 'candidates': candidates, + 'stats': stats, + 'categories': SpecialistCategoryEnum.choices, + 'seniorities': SeniorityEnum.choices, + 'statuses': CandidateStatusEnum.choices, + 'selected_category': category, + 'selected_seniority': seniority, + 'selected_trl': trl, + 'selected_status': status, + 'query': query, + }) + +@login_required +def candidate_create(request): + if request.method == 'POST': + form = SpecialistCandidateForm(request.POST) + if form.is_valid(): + cand = form.save() + messages.success(request, f"Especialista {cand.full_name} registrado en cartera.") + return redirect('talent:candidate_detail', pk=cand.pk) + else: + form = SpecialistCandidateForm() + return render(request, 'talent/candidate_form.html', { + 'form': form, + 'title': 'Registrar Nuevo Especialista I+D+i', + 'regions_json': json.dumps(REGIONS_COMMUNES) + }) + +@login_required +def candidate_edit(request, pk): + cand = get_object_or_404(SpecialistCandidate, pk=pk) + if request.method == 'POST': + form = SpecialistCandidateForm(request.POST, instance=cand) + if form.is_valid(): + cand = form.save() + messages.success(request, f"Perfil de {cand.full_name} actualizado.") + return redirect('talent:candidate_detail', pk=cand.pk) + else: + form = SpecialistCandidateForm(instance=cand) + return render(request, 'talent/candidate_form.html', { + 'form': form, + 'title': f"Editar Especialista: {cand.full_name}", + 'regions_json': json.dumps(REGIONS_COMMUNES) + }) + +@login_required +def candidate_detail(request, pk): + cand = get_object_or_404(SpecialistCandidate, pk=pk) + evaluations = cand.evaluations.select_related('evaluator').order_by('-evaluation_date', '-created_at') + latest_eval = evaluations.first() + + return render(request, 'talent/candidate_detail.html', { + 'candidate': cand, + 'evaluations': evaluations, + 'latest_eval': latest_eval, + }) + +@login_required +def evaluation_create(request, candidate_pk=None): + candidate = get_object_or_404(SpecialistCandidate, pk=candidate_pk) if candidate_pk else None + + if request.method == 'POST': + form = EvaluationRubric7DForm(request.POST) + if form.is_valid(): + ev = form.save(commit=False) + ev.evaluator = request.user + + # Run Scoring Engine + result = TalentScoringEngine.evaluate( + dim_case_study=ev.dim_case_study, + dim_portfolio=ev.dim_portfolio, + dim_tech_specific=ev.dim_tech_specific, + dim_cardinal_skills=ev.dim_cardinal_skills, + dim_experience=ev.dim_experience, + dim_principles_safety=ev.dim_principles_safety, + dim_innovation_rndi=ev.dim_innovation_rndi, + evaluation_date=ev.evaluation_date + ) + + ev.total_weighted_score = result['weighted_score'] + ev.is_knockout = result['is_knockout'] + ev.result_status = result['result_status'] + ev.valid_until = result['valid_until'] + ev.save() + + # Sync candidate status + cand_obj = ev.candidate + cand_obj.status = ev.result_status + cand_obj.save(update_fields=['status']) + + if ev.is_knockout: + messages.error(request, f"Evaluación finalizada: {result['verdict_message']}") + else: + messages.success(request, f"Evaluación 7D guardada exitosamente. Score: {ev.total_weighted_score}/100 - {result['verdict_message']}") + + return redirect('talent:candidate_detail', pk=cand_obj.pk) + else: + initial = {'evaluation_date': date.today()} + if candidate: + initial['candidate'] = candidate + form = EvaluationRubric7DForm(initial=initial) + + return render(request, 'talent/evaluation_form.html', { + 'form': form, + 'candidate': candidate, + }) + +@login_required +def evaluation_detail(request, pk): + eval_obj = get_object_or_404(EvaluationRubric7D.objects.select_related('candidate', 'evaluator'), pk=pk) + return render(request, 'talent/evaluation_detail.html', {'eval': eval_obj}) diff --git a/templates/base.html b/templates/base.html index 9da01f3..a94052d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,7 +10,8 @@ - + + @@ -49,12 +50,21 @@ Resumen Financiero - - Operaciones y Gastos + + Operaciones y Erogaciones - + Control de Proyectos + + Compras & OC + + + Remuneraciones + + + Cartera Talento + {% if 'Administrador Finanzas' in request.user.groups.all.0.name or 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %} Cuentas por Pagar (CXP) @@ -81,10 +91,77 @@ {% block content %}{% endblock %} + + + {% block extra_js %}{% endblock %} diff --git a/templates/expenses/dashboard.html b/templates/expenses/dashboard.html index 8bad793..64e56f2 100644 --- a/templates/expenses/dashboard.html +++ b/templates/expenses/dashboard.html @@ -15,10 +15,10 @@
-
+
- EJECUCIÓN HISTÓRICA / MES + EJECUCIÓN HISTÓRICA ACUMULADA
${{ total_all|floatformat:"0"|intcomma }} CLP
@@ -27,25 +27,17 @@
-
-
- PRESUPUESTO GENERAL I+D+i -
-
-
${{ global_budget|floatformat:"0"|intcomma }} CLP
-
- Saldo Restante: ${{ remaining_budget|floatformat:"0"|intcomma }} CLP -
-
-
- PROYECTOS ACTIVOS + PROYECTOS Y CENTROS ACTIVOS
-
{{ project_count }}
+
{{ project_count_active }} / {{ project_count_total }} Totales
- En línea + + + {{ project_count_active }} Activos · {{ project_count_paused }} Pausados · {{ project_count_completed }} Terminados +
@@ -100,9 +92,14 @@ {% if expense.documents.all %} {% endif %} diff --git a/templates/expenses/expense_form.html b/templates/expenses/expense_form.html index 3475d75..b197125 100644 --- a/templates/expenses/expense_form.html +++ b/templates/expenses/expense_form.html @@ -8,7 +8,7 @@ {% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %} - + {% csrf_token %}
@@ -73,9 +73,15 @@ {% if form.currency.errors %}
{{ form.currency.errors }}
{% endif %}
-
- - {{ form.responsible_person }} +
+ +
+ + {{ form.responsible_person }} + Si NO es rendición, se imputa directamente al Centro de Costos I+D+i. +
{% if form.responsible_person.errors %}
{{ form.responsible_person.errors }}
{% endif %}
@@ -92,14 +98,14 @@ {{ form.vendor }}
{% if form.vendor.errors %}
{{ form.vendor.errors }}
{% endif %} -