iteracion 2

This commit is contained in:
Nachou-u
2026-09-07 18:44:32 -03:00
parent a103a47ae6
commit 09a3f6fb37
93 changed files with 7394 additions and 349 deletions
+27
View File
@@ -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"]
+30
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)
+20
View File
@@ -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}')
+27 -10
View File
@@ -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
+10 -16
View File
@@ -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:
+56
View File
@@ -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:
+55
View File
@@ -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'])
)
+223 -18
View File
@@ -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)
@@ -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'],
},
),
]
@@ -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'],
},
),
]
@@ -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)'),
),
]
@@ -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'),
),
]
+222 -23
View File
@@ -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")
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado")
class Meta:
abstract = True
class Project(CatalogModel):
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado / Director")
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Portafolio / Proyecto Padre")
assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)")
assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)")
actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)")
payment_method = models.CharField(max_length=150, default="Contrato por Fases / Hitos", verbose_name="Forma de Pago")
total_income_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Ingresos Totales (CLP)")
type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro")
status = models.CharField(max_length=50, choices=ProjectStatusEnum.choices, default=ProjectStatusEnum.ACTIVO, verbose_name="Estado de Proyecto")
category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name="Categoría del Proyecto")
created_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")
# 3-step Approval Flow Tracking
is_reimbursement = models.BooleanField(default=False, verbose_name="¿Es Rendición / Reembolso de Gastos?")
approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por")
responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, 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"
+3
View File
@@ -0,0 +1,3 @@
from .sii import SIIService
__all__ = ['SIIService']
+193
View File
@@ -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])
+175 -2
View File
@@ -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())
+13
View File
@@ -12,6 +12,12 @@ urlpatterns = [
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
path('gastos/documento/<int:pk>/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/<int:pk>/', views.expense_report_detail, name='expense_report_detail'),
path('rendiciones/<int:pk>/accion/<str:action>/', 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/<int:pk>/aprobar/<str:action>/', 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/<int:pk>/', views.project_detail, name='project_detail'),
path('proyectos/<int:pk>/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/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
path('gestion/responsables/<int:pk>/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'),
]
+326 -28
View File
@@ -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:
form = VendorForm()
return render(request, 'expenses/vendor_form.html', {'form': form})
# 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:
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']
})
+12
View File
@@ -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('<form method="post" enctype="multipart/form-data">', '<form method="post" enctype="multipart/form-data" x-data="expenseForm()">')
# 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.
+98
View File
@@ -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('<form method="post" enctype="multipart/form-data">', '<form method="post" enctype="multipart/form-data" x-data="expenseForm()">')
# 2. Add x-show to responsible person container
content = content.replace('<div id="responsible-person-container"', '<div id="responsible-person-container" x-show="isReimbursement"')
# 3. Add x-show to new vendor fields
# We need to bind the toggle-new-vendor checkbox
content = content.replace('<input type="checkbox" id="toggle-new-vendor"', '<input type="checkbox" id="toggle-new-vendor" x-model="isNewVendor"')
content = content.replace('<div id="new-vendor-fields"', '<div id="new-vendor-fields" x-show="isNewVendor"')
content = content.replace('style="display: none;', 'style="display: none;" x-cloak ')
# Add x-cloak style to head or just use x-show directly
# Actually replacing the display: none might be tricky, let's just do x-show.
content = content.replace('<div id="new-vendor-fields" style="display: none;', '<div id="new-vendor-fields" x-cloak x-show="isNewVendor" style="')
content = content.replace('<div id="responsible-person-container" x-show="isReimbursement" style="margin-top:0.5rem;">', '<div id="responsible-person-container" x-cloak x-show="isReimbursement" style="margin-top:0.5rem;">')
# Replace the giant vanilla JS <script> with Alpine
# The original script starts at <script> around line 242 and ends at </script> around line 875
script_pattern = re.compile(r'<script>\s*document\.querySelectorAll.*?</script>', re.DOTALL)
alpine_script = """
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('expenseForm', () => ({
isReimbursement: document.getElementById('id_is_reimbursement')?.checked || false,
isNewVendor: false,
amountClp: document.getElementById('id_amount_clp')?.value || '',
taxType: document.getElementById('id_tax_type')?.value || 'EXENTO',
taxAmountClp: document.getElementById('id_tax_amount_clp')?.value || '0',
totalAmountClp: document.getElementById('id_total_amount_clp')?.value || '0',
newVendorRut: '',
rutError: false,
formatAmount() {
let cleanVal = this.amountClp.replace(/\./g, '').replace(/\D/g, '');
if (cleanVal) {
this.amountClp = cleanVal.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ".");
} else {
this.amountClp = '';
}
},
async calculateTax() {
let cleanVal = this.amountClp.replace(/\./g, '');
let amount = parseFloat(cleanVal) || 0;
if (amount > 0 && this.taxType) {
try {
const response = await fetch(`/api/v1/sii/calculate-tax?amount=${amount}&tax_type=${this.taxType}`);
const data = await response.json();
this.taxAmountClp = data.tax_amount.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ".");
this.totalAmountClp = data.total_amount.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ".");
} catch (e) {
console.error('Error calculating tax', e);
}
} else {
this.taxAmountClp = '0';
this.totalAmountClp = '0';
}
},
async validateRut() {
if (!this.newVendorRut) {
this.rutError = false;
return;
}
try {
const response = await fetch(`/api/v1/sii/validate-rut?rut=${this.newVendorRut}`);
const data = await response.json();
if (data.is_valid) {
this.newVendorRut = data.formatted_rut;
this.rutError = false;
} else {
this.rutError = true;
alert(data.error_message || "RUT inválido");
}
} catch (e) {
console.error('Error validating RUT', e);
}
}
}));
});
</script>
<style>
[x-cloak] { display: none !important; }
</style>
"""
content = script_pattern.sub(alpine_script, content)
with open('templates/expenses/expense_form.html', 'w', encoding='utf-8') as f:
f.write(content)
+88
View File
@@ -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)
+17
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# Payroll app
+19
View File
@@ -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')
+6
View File
@@ -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)'
+92
View File
@@ -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'})
)
+133
View File
@@ -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')},
},
),
]
+1
View File
@@ -0,0 +1 @@
# Payroll migrations package
+213
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from .engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService
__all__ = ['PayrollEngine', 'BankExportService', 'PreviredExportService', 'LREExportService']
+352
View File
@@ -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()
+22
View File
@@ -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/<int:pk>/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/<int:pk>/', views.slip_detail, name='slip_detail'),
path('liquidaciones/<int:pk>/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'),
]
+247
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Procurement app
+42
View File
@@ -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',)
+6
View File
@@ -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'
+134
View File
@@ -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
+166
View File
@@ -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'],
},
),
]
View File
+251
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from .matching import ThreeWayMatchingEngine
__all__ = ['ThreeWayMatchingEngine']
+68
View File
@@ -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).'
}
+31
View File
@@ -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/<int:pk>/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/<int:pk>/', views.requisition_detail, name='requisition_detail'),
path('requisiciones/<int:pk>/accion/<str:action>/', 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/<int:pk>/', views.purchase_order_detail, name='purchase_order_detail'),
# Recepción
path('recepcion/nueva/', views.goods_receipt_create, name='goods_receipt_create'),
path('ordenes-compra/<int:po_pk>/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'),
]
+379
View File
@@ -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})
+385
View File
@@ -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))
+24
View File
@@ -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
+132
View File
@@ -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)
+26 -11
View File
@@ -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.")
+217 -53
View File
@@ -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 */
+1
View File
@@ -0,0 +1 @@
# Talent app
+14
View File
@@ -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')
+6
View File
@@ -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'
+66
View File
@@ -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...'}),
}
+76
View File
@@ -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'],
},
),
]
View File
+145
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from .rubric import TalentScoringEngine
__all__ = ['TalentScoringEngine']
+93
View File
@@ -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')),
}
}
+17
View File
@@ -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/<int:pk>/', views.candidate_detail, name='candidate_detail'),
path('especialistas/<int:pk>/editar/', views.candidate_edit, name='candidate_edit'),
# Evaluaciones 7D
path('evaluaciones/nueva/', views.evaluation_create, name='evaluation_create'),
path('especialistas/<int:candidate_pk>/evaluar/', views.evaluation_create, name='evaluation_for_candidate'),
path('evaluaciones/<int:pk>/', views.evaluation_detail, name='evaluation_detail'),
]
+162
View File
@@ -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})
+81 -4
View File
@@ -10,7 +10,8 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="{% static 'css/style.css' %}?v=2.1">
<meta name="csrf-token" content="{{ csrf_token }}">
</head>
<body>
@@ -49,12 +50,21 @@
<a href="{% url 'dashboard' %}" class="nav-tab {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}">
<i data-lucide="pie-chart" style="width:16px;"></i> Resumen Financiero
</a>
<a href="{% url 'expense_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'expense_list' %}active{% endif %}">
<i data-lucide="truck" style="width:16px;"></i> Operaciones y Gastos
<a href="{% url 'expense_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'expense_list' or request.resolver_match.url_name == 'expense_report_list' %}active{% endif %}">
<i data-lucide="truck" style="width:16px;"></i> Operaciones y Erogaciones
</a>
<a href="{% url 'project_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'project_list' %}active{% endif %}">
<a href="{% url 'project_list' %}" class="nav-tab {% if 'project' in request.resolver_match.url_name %}active{% endif %}">
<i data-lucide="folder-kanban" style="width:16px;"></i> Control de Proyectos
</a>
<a href="{% url 'procurement:catalog_list' %}" class="nav-tab {% if 'procurement' in request.resolver_match.namespace %}active{% endif %}">
<i data-lucide="shopping-cart" style="width:16px;"></i> Compras & OC
</a>
<a href="{% url 'payroll:employee_list' %}" class="nav-tab {% if 'payroll' in request.resolver_match.namespace %}active{% endif %}">
<i data-lucide="users" style="width:16px;"></i> Remuneraciones
</a>
<a href="{% url 'talent:candidate_list' %}" class="nav-tab {% if 'talent' in request.resolver_match.namespace %}active{% endif %}">
<i data-lucide="award" style="width:16px;"></i> Cartera Talento
</a>
{% if 'Administrador Finanzas' in request.user.groups.all.0.name or 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'accounts_payable' %}" class="nav-tab {% if request.resolver_match.url_name == 'accounts_payable' %}active{% endif %}">
<i data-lucide="calculator" style="width:16px;"></i> Cuentas por Pagar (CXP)
@@ -81,10 +91,77 @@
{% block content %}{% endblock %}
</main>
<!-- Requerimiento 3: Modal Universal de Previsualización de Evidencias (JPG/PNG/PDF) -->
<div id="evidencePreviewModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(15,23,42,0.85); z-index:9999; backdrop-filter:blur(4px); align-items:center; justify-content:center;">
<div style="background:white; border-radius:12px; width:90%; max-width:900px; height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);">
<div style="padding:1rem 1.5rem; background:var(--efintel-dark, #1e293b); color:white; display:flex; justify-content:space-between; align-items:center;">
<h3 id="previewModalTitle" style="margin:0; font-size:1.1rem; font-weight:700; color:white;">Vista Previa de Evidencia</h3>
<div style="display:flex; gap:0.5rem; align-items:center;">
<a id="previewModalDownloadBtn" href="#" target="_blank" class="btn-primary" style="padding:0.3rem 0.8rem; font-size:0.8rem; text-decoration:none; background:#0284c7;">
<i data-lucide="download" style="width:14px;"></i> Descargar
</a>
<button onclick="closePreviewModal()" style="background:transparent; border:none; color:white; font-size:1.5rem; cursor:pointer; padding:0 0.5rem;">&times;</button>
</div>
</div>
<div id="previewModalContent" style="flex:1; background:#f8fafc; display:flex; align-items:center; justify-content:center; padding:1rem; overflow:auto;">
<!-- Contenido dinámico embebido (Image o PDF) -->
</div>
</div>
</div>
<script>
if (window.lucide) {
lucide.createIcons();
}
function previewDocument(fileUrl, title, isPdf) {
const modal = document.getElementById('evidencePreviewModal');
const modalTitle = document.getElementById('previewModalTitle');
const downloadBtn = document.getElementById('previewModalDownloadBtn');
const content = document.getElementById('previewModalContent');
modalTitle.textContent = title || 'Vista Previa de Evidencia';
downloadBtn.href = fileUrl;
if (isPdf || fileUrl.toLowerCase().endsWith('.pdf')) {
content.innerHTML = `<object data="${fileUrl}" type="application/pdf" width="100%" height="100%" style="min-height:70vh; border:none;">
<iframe src="${fileUrl}" width="100%" height="100%" style="min-height:70vh; border:none;">
Tu navegador no soporta visualización directa de PDF. <a href="${fileUrl}" target="_blank">Haz clic aquí para descargar.</a>
</iframe>
</object>`;
} else {
content.innerHTML = `<img src="${fileUrl}" alt="Evidencia" style="max-width:100%; max-height:75vh; object-fit:contain; border-radius:8px; box-shadow:0 4px 6px -1px rgba(0,0,0,0.1);" />`;
}
modal.style.display = 'flex';
}
function closePreviewModal() {
const modal = document.getElementById('evidencePreviewModal');
const content = document.getElementById('previewModalContent');
modal.style.display = 'none';
content.innerHTML = '';
}
// Formateador automático global para cualquier formulario presente o futuro
function styleAllFormControls() {
document.querySelectorAll('form input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="hidden"]), form select, form textarea').forEach(function(el) {
if (!el.classList.contains('form-control') && !el.classList.contains('form-input')) {
el.classList.add('form-control');
}
});
}
document.addEventListener('DOMContentLoaded', styleAllFormControls);
const formObserver = new MutationObserver(styleAllFormControls);
if (document.body) {
formObserver.observe(document.body, { childList: true, subtree: true });
}
// Cerrar modal al presionar Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePreviewModal();
});
</script>
{% block extra_js %}{% endblock %}
</body>
+15 -18
View File
@@ -15,10 +15,10 @@
</div>
</div>
<div class="kpi-grid-3">
<div class="kpi-grid-2" style="display:grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;">
<div class="kpi-card accent-blue">
<div class="kpi-header">
<span class="kpi-title">EJECUCIÓN HISTÓRICA / MES</span>
<span class="kpi-title">EJECUCIÓN HISTÓRICA ACUMULADA</span>
<div class="kpi-icon"><i data-lucide="credit-card"></i></div>
</div>
<div class="kpi-value" id="kpi-spend">${{ total_all|floatformat:"0"|intcomma }} CLP</div>
@@ -27,25 +27,17 @@
</div>
</div>
<div class="kpi-card accent-turquoise">
<div class="kpi-header">
<span class="kpi-title">PRESUPUESTO GENERAL I+D+i</span>
<div class="kpi-icon"><i data-lucide="wallet"></i></div>
</div>
<div class="kpi-value" id="kpi-remaining">${{ global_budget|floatformat:"0"|intcomma }} CLP</div>
<div class="kpi-subtext">
<span>Saldo Restante: ${{ remaining_budget|floatformat:"0"|intcomma }} CLP</span>
</div>
</div>
<div class="kpi-card accent-success">
<div class="kpi-header">
<span class="kpi-title">PROYECTOS ACTIVOS</span>
<span class="kpi-title">PROYECTOS Y CENTROS ACTIVOS</span>
<div class="kpi-icon"><i data-lucide="trending-up"></i></div>
</div>
<div class="kpi-value" id="kpi-evm">{{ project_count }}</div>
<div class="kpi-value" id="kpi-evm">{{ project_count_active }} <span style="font-size:1rem; font-weight:500; color:var(--efintel-gray);">/ {{ project_count_total }} Totales</span></div>
<div class="kpi-subtext">
<span class="status-dot green"></span> <span style="font-weight:600; color:var(--efintel-dark);">En línea</span>
<span class="status-dot green"></span>
<span style="font-weight:600; color:var(--efintel-dark);">
{{ project_count_active }} Activos · {{ project_count_paused }} Pausados · {{ project_count_completed }} Terminados
</span>
</div>
</div>
</div>
@@ -100,9 +92,14 @@
{% if expense.documents.all %}
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %}
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
</a>
<button type="button" onclick="previewDocument('{{ doc.file.url }}', '{{ doc.title|escapejs }}', {% if doc.is_pdf %}true{% else %}false{% endif %})" style="background:none; border:none; color:#0284c7; cursor:pointer; padding:0; font-size:0.65rem;" title="Vista Previa Modal">
<i data-lucide="eye" style="width:12px;"></i>
</button>
</div>
{% endfor %}
</div>
{% endif %}
+173 -65
View File
@@ -8,7 +8,7 @@
{% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %}
</h2>
<form method="post" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data" x-data="expenseForm()">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
@@ -73,9 +73,15 @@
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.responsible_person.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Responsable Solicitante</label>
<div class="form-group" style="background:#f8fafc; padding:0.75rem; border-radius:6px; border:1px solid #e2e8f0;">
<label style="font-weight: 700; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
{{ form.is_reimbursement }} ¿Es Rendición de Gastos?
</label>
<div id="responsible-person-container" style="margin-top:0.5rem;" x-show="isReimbursement" x-transition x-cloak>
<label for="{{ form.responsible_person.id_for_label }}" style="font-weight: 600; font-size: 0.75rem; color: var(--efintel-dark);">Responsable de Rendición</label>
{{ form.responsible_person }}
<small style="color:var(--efintel-gray); font-size:0.68rem; display:block;">Si NO es rendición, se imputa directamente al Centro de Costos I+D+i.</small>
</div>
{% if form.responsible_person.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.responsible_person.errors }}</div>{% endif %}
</div>
</div>
@@ -92,14 +98,14 @@
{{ form.vendor }}
<div style="margin-top: 0.5rem;">
<label style="font-size: 0.75rem; display: flex; align-items: center; gap: 0.35rem; cursor: pointer; color: var(--efintel-blue); font-weight: 600;">
<input type="checkbox" id="toggle-new-vendor" style="width: auto; margin: 0; cursor: pointer;"> Registrar un nuevo proveedor
<input type="checkbox" id="toggle-new-vendor" style="width: auto; margin: 0; cursor: pointer;" x-model="isNewVendor"> Registrar un nuevo proveedor
</label>
</div>
{% if form.vendor.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.vendor.errors }}</div>{% endif %}
</div>
</div>
<div id="new-vendor-fields" style="display: none; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; background: #f0fdf4; padding: 1.25rem; border-radius: 6px; border: 1px solid #bbf7d0; box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); align-items: end;">
<div id="new-vendor-fields" x-show="isNewVendor" x-transition x-cloak style="grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; background: #f0fdf4; padding: 1.25rem; border-radius: 6px; border: 1px solid #bbf7d0; box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); align-items: end; display: grid;">
<div class="form-group" style="display:flex; flex-direction:column;">
<label for="id_new_vendor_rut" style="font-weight: 600; font-size: 0.85rem; color: #166534; margin-bottom: 0.35rem;">RUT del Nuevo Proveedor</label>
{{ form.new_vendor_rut }}
@@ -164,20 +170,67 @@
{% endif %}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 2rem; background: var(--efintel-light); padding: 1.5rem; border-radius: 6px;">
<h4 style="grid-column: 1 / -1; margin: 0 0 1rem 0; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="map-pin" style="width:14px;"></i> Datos Logísticos (Opcional)</h4>
<!-- Requerimiento 14: El apartado de vehículo SOLO aplica para erogaciones de Combustible (3101) -->
<div id="vehicle-log-container" style="display: none; margin-bottom: 2rem; background: #fffbebfb; padding: 1.5rem; border-radius: 8px; border: 1px solid #fef3c7;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9rem; color: #92400e; display:flex; align-items:center; gap:0.4rem;">
<i data-lucide="car" style="width:16px;"></i> Informes Bitácora de Vehículo y Combustible (Requerido para Categoría Combustibles)
</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div class="form-group">
<label for="{{ form.vehicle_ownership.id_for_label }}" style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Modalidad / Vehículo</label>
{{ form.vehicle_ownership }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Patente Vehículo</label>
{{ form.v_license_plate }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Conductor / Responsable</label>
{{ form.v_driver_name }}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Odómetro Inicial (KM)</label>
{{ form.v_odometer_start }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Odómetro Final (KM)</label>
{{ form.v_odometer_end }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Origen</label>
{{ form.v_route_origin }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Destino / Faena</label>
{{ form.v_route_destination }}
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 2rem; background: var(--efintel-light); padding: 1.25rem; border-radius: 6px;">
<h4 style="grid-column: 1 / -1; margin: 0 0 0.75rem 0; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="map-pin" style="width:14px;"></i> Ubiación Geográfica Operativa (Opcional)</h4>
<div class="form-group">
<label for="{{ form.region.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Región Operativa</label>
{{ form.region }}
<select name="region" id="id_region" x-model="selectedRegion" @change="updateComunas()">
<option value="">--- Seleccione Región ---</option>
<template x-for="(communes, regionName) in regionsData" :key="regionName">
<option :value="regionName" x-text="regionName" :selected="regionName === selectedRegion"></option>
</template>
</select>
</div>
<div class="form-group">
<label for="{{ form.comuna.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Comuna</label>
{{ form.comuna }}
</div>
<div class="form-group">
<label for="{{ form.vehicle_ownership.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Vehículo Asignado</label>
{{ form.vehicle_ownership }}
<select name="comuna" id="id_comuna" x-model="selectedComuna">
<option value="">--- Seleccione Comuna ---</option>
<template x-for="comunaName in availableComunas" :key="comunaName">
<option :value="comunaName" x-text="comunaName" :selected="comunaName === selectedComuna"></option>
</template>
</select>
</div>
</div>
@@ -196,6 +249,98 @@
{{ regions_communes_json|safe }}
</script>
<style>
[x-cloak] { display: none !important; }
</style>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('expenseForm', () => ({
isReimbursement: document.getElementById('id_is_reimbursement')?.checked || false,
isNewVendor: false,
amountClp: document.getElementById('id_amount_clp')?.value || '',
taxType: document.getElementById('id_tax_type')?.value || 'EXENTO',
taxAmountClp: document.getElementById('id_tax_amount_clp')?.value || '0',
totalAmountClp: document.getElementById('id_total_amount_clp')?.value || '0',
newVendorRut: '',
rutError: false,
regionsData: JSON.parse(document.getElementById('regions-communes-data').textContent || '{}'),
selectedRegion: '{{ form.instance.region|default:"" }}',
selectedComuna: '{{ form.instance.comuna|default:"" }}',
availableComunas: [],
init() {
if (this.selectedRegion) {
this.updateComunas();
}
},
updateComunas() {
this.availableComunas = this.regionsData[this.selectedRegion] || [];
if (!this.availableComunas.includes(this.selectedComuna)) {
this.selectedComuna = '';
}
},
formatAmount() {
let cleanVal = this.amountClp.replace(/\./g, '').replace(/\D/g, '');
if (cleanVal) {
this.amountClp = cleanVal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
} else {
this.amountClp = '';
}
},
async calculateTax() {
let cleanVal = this.amountClp.replace(/\./g, '');
let amount = parseFloat(cleanVal) || 0;
if (amount > 0 && this.taxType) {
try {
const response = await fetch(`/api/v1/sii/calculate-tax?amount=${amount}&tax_type=${this.taxType}`);
const data = await response.json();
this.taxAmountClp = data.tax_amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
this.totalAmountClp = data.total_amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
} catch (e) {
console.error('Error calculating tax', e);
}
} else {
this.taxAmountClp = '0';
this.totalAmountClp = '0';
}
},
async validateRut() {
if (!this.newVendorRut) {
this.rutError = false;
return;
}
try {
const response = await fetch(`/api/v1/sii/validate-rut?rut=${this.newVendorRut}`);
const data = await response.json();
if (data.is_valid) {
this.newVendorRut = data.formatted_rut;
this.rutError = false;
} else {
this.rutError = true;
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'RUT Inválido',
text: data.error_message || "El formato del RUT no es válido.",
confirmButtonColor: 'var(--efintel-blue)'
});
}
}
} catch (e) {
console.error('Error validating RUT', e);
}
}
}));
});
</script>
<script>
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
el.style.width = '100%';
@@ -207,63 +352,26 @@
el.style.boxSizing = 'border-box';
});
const amountInput = document.getElementById('id_amount_clp');
const taxTypeSelect = document.getElementById('id_tax_type');
const taxAmountInput = document.getElementById('id_tax_amount_clp');
const totalAmountInput = document.getElementById('id_total_amount_clp');
// AlpineJS manages tax calculation and toggles now.
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
// Requerimiento 14: Apartado de Vehículo SOLO para Combustibles (3101)
const vehicleLogContainer = document.getElementById('vehicle-log-container');
function toggleVehicleLog() {
if (!categorySelect || !vehicleLogContainer) return;
const selText = categorySelect.options[categorySelect.selectedIndex] ? categorySelect.options[categorySelect.selectedIndex].textContent.toLowerCase() : '';
const selVal = categorySelect.value;
function cleanNumberString(str) {
return str.replace(/\./g, '');
}
function calculateTaxes() {
const cleanVal = cleanNumberString(amountInput.value);
const amount = parseFloat(cleanVal) || 0;
const taxType = taxTypeSelect.value;
let taxAmount = 0;
if (taxType === 'IVA_19') {
taxAmount = amount * 0.19;
} else if (taxType === 'HONORARIOS_13_75') {
taxAmount = amount * 0.1375;
}
taxAmountInput.value = formatNumber(Math.round(taxAmount));
totalAmountInput.value = formatNumber(Math.round(amount + taxAmount));
}
amountInput.addEventListener('input', (e) => {
let cursorPosition = e.target.selectionStart;
const oldLength = e.target.value.length;
const cleanVal = cleanNumberString(e.target.value).replace(/\D/g, '');
if (cleanVal) {
e.target.value = formatNumber(cleanVal);
if (selText.includes('3101') || selText.includes('combustible')) {
vehicleLogContainer.style.display = 'block';
} else {
e.target.value = '';
vehicleLogContainer.style.display = 'none';
}
}
if (categorySelect) {
categorySelect.addEventListener('change', toggleVehicleLog);
toggleVehicleLog();
}
const newLength = e.target.value.length;
cursorPosition = cursorPosition + (newLength - oldLength);
e.target.setSelectionRange(cursorPosition, cursorPosition);
calculateTaxes();
});
taxTypeSelect.addEventListener('change', calculateTaxes);
// Initial formatting on load
if (amountInput.value) {
const cleanVal = cleanNumberString(amountInput.value).replace(/\D/g, '');
amountInput.value = formatNumber(cleanVal);
}
calculateTaxes();
// Custom Searchable Dropdown for Category
const categorySelect = document.getElementById('id_category');
const searchInput = document.getElementById('category-search-input');
const dropdownList = document.getElementById('category-dropdown-list');
+7 -2
View File
@@ -70,9 +70,14 @@
{% if expense.documents.all %}
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %}
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
</a>
<button type="button" onclick="previewDocument('{{ doc.file.url }}', '{{ doc.title|escapejs }}', {% if doc.is_pdf %}true{% else %}false{% endif %})" style="background:none; border:none; color:#0284c7; cursor:pointer; padding:0; font-size:0.65rem;" title="Vista Previa Modal">
<i data-lucide="eye" style="width:12px;"></i> Ver
</button>
</div>
{% endfor %}
</div>
{% endif %}
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-plus" style="color:var(--efintel-blue);"></i> {{ title }}</h2>
<p>Creación y edición de informe agrupador de rendición de gastos.</p>
</div>
<div>
<a href="{% url 'expense_report_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'expense_report_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="save" style="width:16px;"></i> Guardar Informe Rendición</button>
</div>
</form>
</div>
</section>
{% endblock %}
@@ -0,0 +1,53 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-spreadsheets" style="color:var(--efintel-blue);"></i> Informes y Rendiciones de Gastos</h2>
<p>Agrupación de boletas/facturas por comisiones de servicio, viajes a faena y cajas chicas.</p>
</div>
<div>
<a href="{% url 'expense_report_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Nueva Rendición
</a>
</div>
</div>
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Título / Propósito</th>
<th>Responsable</th>
<th>Proyecto</th>
<th>Fechas</th>
<th>Monto Total (CLP)</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for rep in reports %}
<tr>
<td><strong>{{ rep.report_code }}</strong></td>
<td>{{ rep.title }}</td>
<td>{{ rep.user.username }}</td>
<td>{{ rep.project.name }}</td>
<td>{{ rep.start_date }} al {{ rep.end_date }}</td>
<td>${{ rep.total_amount|floatformat:"0"|intcomma }} CLP</td>
<td><span class="badge badge-blue">{{ rep.get_status_display }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay informes de rendición registrados. <a href="{% url 'expense_report_create' %}">Crear el primero</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+13 -7
View File
@@ -391,17 +391,23 @@
<div id="tab-config" class="tab-content">
<div style="margin-bottom: 1.5rem;">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Configuración General del Sistema</h2>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Parámetros y límites globales del ERP.</p>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Parámetros de Impuestos y Tasas globales del ERP.</p>
</div>
<div style="max-width: 500px; display: flex; flex-direction: column; gap: 1.5rem;">
<div class="data-card" style="padding: 2rem;">
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem;">Presupuesto General Asignado</div>
<div style="font-size: 2.5rem; font-weight: 700; color: var(--efintel-petroleum);">${{ settings.global_budget|floatformat:"0"|intcomma|default:"100000" }}</div>
<div style="max-width: 600px; display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="data-card" style="padding: 1.5rem;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.5rem; font-weight:600;">Tasa de IVA (% Dinámico SII)</div>
<div style="font-size: 2.2rem; font-weight: 700; color: var(--efintel-petroleum);">{{ settings.iva_rate|default:"19.00" }}%</div>
</div>
<div>
<a href="{% url 'settings_edit' %}" class="btn-primary" style="text-decoration: none;">Modificar Presupuesto General</a>
<div class="data-card" style="padding: 1.5rem;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.5rem; font-weight:600;">Tasa de Cambio USD / CLP</div>
<div style="font-size: 2.2rem; font-weight: 700; color: var(--efintel-petroleum);">${{ settings.usd_exchange_rate|floatformat:"0"|default:"950" }}</div>
</div>
</div>
<div style="margin-top: 1.5rem;">
<a href="{% url 'settings_edit' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="sliders" style="width:16px;"></i> Modificar Parámetros del Sistema
</a>
</div>
</div>
<!-- Pestaña Bitácora -->
+207
View File
@@ -0,0 +1,207 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<!-- Header -->
<div class="view-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<div style="display:flex; align-items:center; gap:0.5rem;">
<span class="badge badge-blue" style="font-family:var(--font-mono); font-size:0.8rem;">{{ project.code }}</span>
<h2 style="margin:0; color:var(--efintel-petroleum); font-size:1.5rem;">{{ project.name }}</h2>
<span class="badge {% if project.status == 'ACTIVO' %}badge-emerald{% elif project.status == 'PAUSADA' %}badge-amber{% elif project.status == 'BORRADOR' %}badge-gray{% else %}badge-blue{% endif %}">
{{ project.get_status_display }}
</span>
</div>
<p style="margin-top:0.25rem; font-size:0.9rem; color:var(--efintel-gray);">
Director de Proyecto: <strong>{{ project.manager.username|default:"Sin Director Asignado" }}</strong> |
Tipo: <strong>{{ project.get_type_display }}</strong> |
Categoría: <strong>{{ project.category.name|default:"General I+D+i" }}</strong>
</p>
</div>
<div style="display:flex; gap:0.5rem;">
<a href="{% url 'project_list' %}" class="btn-outline" style="text-decoration:none;">&larr; Volver a Lista</a>
{% if is_admin or is_pm %}
<a href="{% url 'project_add_phase' project.id %}" class="btn-primary" style="text-decoration:none; background:#0284c7;">
<i data-lucide="plus-circle" style="width:14px;"></i> + Agregar Fase de Pago
</a>
<a href="{% url 'project_edit' project.id %}" class="btn-secondary" style="text-decoration:none;">
<i data-lucide="pencil" style="width:14px;"></i> Editar
</a>
{% endif %}
</div>
</div>
<!-- Requerimiento 9: Tarjetas KPI de Finanzas del Proyecto -->
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem; margin-bottom: 2rem;">
<div class="data-card" style="padding: 1.25rem;">
<div style="font-size:0.75rem; font-weight:600; color:var(--efintel-gray); margin-bottom:0.25rem;">PRESUPUESTO ASIGNADO</div>
<div style="font-size:1.3rem; font-weight:800; font-family:var(--font-mono); color:var(--efintel-petroleum);">
${{ project.assigned_budget_clp|floatformat:"0"|intcomma }} CLP
</div>
<div style="font-size:0.7rem; color:var(--efintel-gray); margin-top:0.25rem;">Presupuesto CLP</div>
</div>
<div class="data-card" style="padding: 1.25rem;">
<div style="font-size:0.75rem; font-weight:600; color:var(--efintel-gray); margin-bottom:0.25rem;">FORMA DE PAGO</div>
<div style="font-size:0.95rem; font-weight:700; color:var(--efintel-dark); line-height:1.2; margin-top:0.25rem;">
{{ project.payment_method }}
</div>
<div style="font-size:0.7rem; color:var(--efintel-gray); margin-top:0.25rem;">Condición Pactada</div>
</div>
<div class="data-card" style="padding: 1.25rem; border-left:4px solid #16a34a;">
<div style="font-size:0.75rem; font-weight:600; color:#16a34a; margin-bottom:0.25rem;">INGRESOS TOTALES</div>
<div style="font-size:1.3rem; font-weight:800; font-family:var(--font-mono); color:#16a34a;">
+${{ total_income|floatformat:"0"|intcomma }} CLP
</div>
<div style="font-size:0.7rem; color:var(--efintel-gray); margin-top:0.25rem;">Facturado / Cobrado</div>
</div>
<div class="data-card" style="padding: 1.25rem; border-left:4px solid #dc2626;">
<div style="font-size:0.75rem; font-weight:600; color:#dc2626; margin-bottom:0.25rem;">EGRESOS ACUMULADOS</div>
<div style="font-size:1.3rem; font-weight:800; font-family:var(--font-mono); color:#dc2626;">
-${{ total_expenses|floatformat:"0"|intcomma }} CLP
</div>
<div style="font-size:0.7rem; color:var(--efintel-gray); margin-top:0.25rem;">Erogaciones Liberadas</div>
</div>
<div class="data-card" style="padding: 1.25rem; border-left:4px solid {% if net_balance >= 0 %}#16a34a{% else %}#dc2626{% endif %}; background:{% if net_balance >= 0 %}#f0fdf4{% else %}#fef2f2{% endif %};">
<div style="font-size:0.75rem; font-weight:700; color:{% if net_balance >= 0 %}#15803d{% else %}#b91c1c{% endif %}; margin-bottom:0.25rem;">SALDO NETO (A FAVOR)</div>
<div style="font-size:1.3rem; font-weight:800; font-family:var(--font-mono); color:{% if net_balance >= 0 %}#15803d{% else %}#b91c1c{% endif %};">
{% if net_balance >= 0 %}+{% endif %}${{ net_balance|floatformat:"0"|intcomma }} CLP
</div>
<div style="font-size:0.7rem; color:{% if net_balance >= 0 %}#166534{% else %}#991b1b{% endif %}; margin-top:0.25rem;">
{% if net_balance >= 0 %}🟢 Saldo a Favor Positivo{% else %}🔴 Déficit / Cuenta en Rojo{% endif %}
</div>
</div>
</div>
<!-- Requerimiento 10: Tabla de Pago de Fases con Saldo a Favor (+ / -) -->
<div class="data-card" style="margin-bottom: 2rem;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; padding-bottom:0.75rem; border-bottom:1px solid #e2e8f0;">
<h3 style="margin:0; color:var(--efintel-petroleum); font-size:1.1rem; display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="layers" style="color:var(--efintel-blue);"></i> Fases de Pago e Hitos del Proyecto
</h3>
{% if is_admin or is_pm %}
<a href="{% url 'project_add_phase' project.id %}" class="btn-primary" style="padding:0.35rem 0.8rem; font-size:0.8rem; text-decoration:none; background:#0284c7;">
+ Agregar Nueva Fase
</a>
{% endif %}
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>N° Fase</th>
<th>Nombre del Hito</th>
<th>Fecha Pactada</th>
<th>Monto Pactado (CLP)</th>
<th>Ingresos (Cobrado)</th>
<th>Egresos (Gastado)</th>
<th>Saldo a Favor (Positivo / Negativo)</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for phase in phases %}
<tr>
<td style="font-weight:700; text-align:center;">Fase {{ phase.phase_number }}</td>
<td style="font-weight:600; color:var(--efintel-dark);">
{{ phase.name }}
{% if phase.description %}<br><span style="font-size:0.75rem; color:var(--efintel-gray); font-weight:normal;">{{ phase.description }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;">{{ phase.due_date|date:"d/m/Y"|default:"Pendiente" }}</td>
<td style="font-family:var(--font-mono); font-weight:600;">${{ phase.planned_amount_clp|floatformat:"0"|intcomma }}</td>
<td style="font-family:var(--font-mono); color:#16a34a; font-weight:700;">+${{ phase.income_amount_clp|floatformat:"0"|intcomma }}</td>
<td style="font-family:var(--font-mono); color:#dc2626; font-weight:700;">-${{ phase.expense_amount_clp|floatformat:"0"|intcomma }}</td>
<!-- Requerimiento 10: Reflejar saldo a favor (números negativos y positivos) -->
<td>
<span style="font-family:var(--font-mono); font-weight:800; padding:0.25rem 0.6rem; border-radius:4px; font-size:0.85rem; {% if phase.balance_clp >= 0 %}background:#dcfce7; color:#15803d;{% else %}background:#fee2e2; color:#b91c1c;{% endif %}">
{% if phase.balance_clp >= 0 %}+{% endif %}${{ phase.balance_clp|floatformat:"0"|intcomma }} CLP
</span>
</td>
<td>
{% if phase.is_completed %}
<span class="badge badge-emerald"><i data-lucide="check" style="width:12px;"></i> Completada</span>
{% else %}
<span class="badge badge-amber"><i data-lucide="clock" style="width:12px;"></i> En Proceso</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--efintel-gray);">
No se han definido fases de pago para este proyecto.
{% if is_admin or is_pm %}<a href="{% url 'project_add_phase' project.id %}">Haga clic aquí para agregar la primera fase.</a>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Tabla de Erogaciones Asociadas al Proyecto -->
<div class="data-card">
<h3 style="margin:0 0 1rem 0; color:var(--efintel-petroleum); font-size:1.1rem; display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="file-text" style="color:var(--efintel-blue);"></i> Erogaciones Imputadas a este Proyecto ({{ expenses.count }})
</h3>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Fecha</th>
<th>Categoría</th>
<th>Detalle</th>
<th>Responsable</th>
<th>Monto Neto (CLP)</th>
<th>Monto Total (CLP)</th>
<th>Estado Pago</th>
<th>Evidencias</th>
</tr>
</thead>
<tbody>
{% for exp in expenses %}
<tr>
<td style="font-family:var(--font-mono); font-size:0.75rem;">{{ exp.expense_code|truncatechars:12 }}</td>
<td style="font-size:0.8rem;">{{ exp.date|date:"d/m/Y" }}</td>
<td><span class="badge badge-blue" style="font-size:0.7rem;">{{ exp.category.name|default:"General" }}</span></td>
<td style="font-weight:600; font-size:0.85rem;">{{ exp.description|truncatechars:40 }}</td>
<td style="font-size:0.8rem;">{{ exp.responsible_person.username|default:exp.user.username }}</td>
<td style="font-family:var(--font-mono); font-weight:600;">${{ exp.amount_clp|floatformat:"0"|intcomma }}</td>
<td style="font-family:var(--font-mono); font-weight:700;">${{ exp.total_amount_clp|floatformat:"0"|intcomma }}</td>
<td>
{% if exp.approval_status == 'LIBERADO_PAGADO' %}
<span class="badge badge-emerald">Pagado</span>
{% elif exp.approval_status == 'SOLICITADO_FINANZAS' %}
<span class="badge badge-blue">Finanzas</span>
{% else %}
<span class="badge badge-amber">Revisión</span>
{% endif %}
</td>
<td>
{% if exp.documents.all %}
{% for doc in exp.documents.all %}
<button type="button" onclick="previewDocument('{{ doc.file.url }}', '{{ doc.title|escapejs }}', {% if doc.is_pdf %}true{% else %}false{% endif %})" class="btn-outline" style="padding:0.2rem 0.4rem; font-size:0.65rem;" title="Previsualizar Evidencia">
<i data-lucide="eye" style="width:10px;"></i> Ver
</button>
{% endfor %}
{% else %}
<span style="font-size:0.75rem; color:var(--efintel-gray);">Sin adjunto</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" style="text-align:center; padding:2rem; color:var(--efintel-gray);">No hay erogaciones registradas para este proyecto.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+53 -44
View File
@@ -11,39 +11,71 @@
<form method="post">
{% csrf_token %}
<!-- Requerimiento 8: Creación Progresiva Wizard -->
<div style="margin-bottom: 2rem; display:flex; justify-content:space-between; background:#f8fafc; padding:1rem; border-radius:6px; border:1px solid #e2e8f0;">
<div style="display:flex; gap:0.5rem; align-items:center;">
<span class="badge badge-blue">Paso 1: Clasificación & Director</span>
<span class="badge badge-gray">&rarr; Paso 2: Presupuesto & Condición Comercial</span>
</div>
<span style="font-size:0.75rem; color:var(--efintel-gray);">Estado inicial: Borrador o Activo</span>
</div>
<h3 style="font-size:1rem; color:var(--efintel-petroleum); margin-bottom:1rem;">1. Identificación y Responsable del Proyecto</h3>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.code.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Código</label>
<label for="{{ form.code.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Código Correlativo</label>
{{ form.code }}
{% if form.code.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>
{% endif %}
{% if form.code.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.name.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Nombre del Proyecto / Centro</label>
{{ form.name }}
{% if form.name.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>
{% endif %}
{% if form.name.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.manager.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Director / Manager</label>
{{ form.manager }}
{% if form.manager.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.manager.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Centro</label>
{{ form.type }}
{% if form.type.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.type.errors }}</div>
{% endif %}
{% if form.type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.type.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.status.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Estado del Proyecto</label>
{{ form.status }}
{% if form.status.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.status.errors }}</div>{% endif %}
</div>
</div>
<!-- Requerimiento 9: Presupuesto Asignado, Forma de Pago e Ingresos Totales -->
<h3 style="font-size:1rem; color:var(--efintel-petroleum); margin-top:2rem; margin-bottom:1rem; border-top:1px solid #e2e8f0; padding-top:1.5rem;">2. Presupuesto, Forma de Pago e Ingresos Pactados</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.assigned_budget_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Presupuesto Asignado (CLP)</label>
{{ form.assigned_budget_clp }}
{% if form.assigned_budget_clp.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.assigned_budget_clp.errors }}</div>
{% endif %}
{% if form.assigned_budget_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.assigned_budget_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.payment_method.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Forma de Pago</label>
{{ form.payment_method }}
{% if form.payment_method.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.payment_method.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.total_income_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: #16a34a;">Ingresos Totales Contratados (CLP)</label>
{{ form.total_income_clp }}
{% if form.total_income_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.total_income_clp.errors }}</div>{% endif %}
</div>
</div>
@@ -51,48 +83,25 @@
<div class="form-group">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría del Proyecto (Especialidad)</label>
{{ form.category }}
{% if form.category.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>
{% endif %}
{% if form.category.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.status.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Estado del Proyecto</label>
{{ form.status }}
{% if form.status.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.status.errors }}</div>
{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.parent.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Portafolio Padre (Opcional)</label>
<p style="font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem;">Dejar en blanco si este proyecto actúa como un Portafolio principal.</p>
{{ form.parent }}
{% if form.parent.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>
{% endif %}
{% if form.parent.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Descripción</label>
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Descripción / Objetivos</label>
{{ form.description }}
{% if form.description.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>
{% endif %}
</div>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="{{ form.manager.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Encargado / Responsable del Proyecto</label>
{{ form.manager }}
{% if form.manager.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.manager.errors }}</div>
{% endif %}
{% if form.description.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>{% endif %}
</div>
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Proyecto</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem; background:var(--efintel-blue);">Guardar Proyecto</button>
<a href="{% url 'project_list' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
</div>
+101 -26
View File
@@ -2,47 +2,122 @@
{% load humanize %}
{% block content %}
<div class="view-header">
<div class="view-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div class="view-title">
<h2>Proyectos</h2>
<p>Monitoreo de Centros de Costo</p>
<h2 style="margin:0; color:var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="folder-kanban" style="color:var(--efintel-blue);"></i> Control de Proyectos y Centros de Costo
</h2>
<p style="margin-top:0.25rem; font-size:0.9rem; color:var(--efintel-gray);">Supervisión de presupuesto, forma de pago, ingresos, egresos y saldos por proyecto.</p>
</div>
<div>
{% if is_admin or is_pm %}
<a href="{% url 'project_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Nuevo Proyecto
</a>
{% endif %}
</div>
<a href="{% url 'project_create' %}" class="btn-primary">Nuevo Proyecto / Centro</a>
</div>
<div class="project-grid">
<!-- Requerimiento 2: Conteo exacto por estado -->
<div style="display:grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem;">
<div class="data-card" style="padding: 1rem; text-align: center; border-left: 4px solid #16a34a;">
<div style="font-size: 0.75rem; color: var(--efintel-gray); font-weight:600;">PROYECTOS ACTIVOS</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #16a34a;">{{ project_count_active }}</div>
</div>
<div class="data-card" style="padding: 1rem; text-align: center; border-left: 4px solid #d97706;">
<div style="font-size: 0.75rem; color: var(--efintel-gray); font-weight:600;">EN PAUSA</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #d97706;">{{ project_count_paused }}</div>
</div>
<div class="data-card" style="padding: 1rem; text-align: center; border-left: 4px solid #0284c7;">
<div style="font-size: 0.75rem; color: var(--efintel-gray); font-weight:600;">TERMINADOS</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #0284c7;">{{ project_count_completed }}</div>
</div>
<div class="data-card" style="padding: 1rem; text-align: center; border-left: 4px solid #64748b;">
<div style="font-size: 0.75rem; color: var(--efintel-gray); font-weight:600;">TOTAL EN SISTEMA</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #334155;">{{ project_count_total }}</div>
</div>
</div>
<div class="data-card" style="margin-bottom: 2rem;">
<form method="get" id="filter-form" style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;">
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label for="search" style="font-size: 0.8rem; font-weight: 600;">Buscar por nombre o código</label>
<input type="text" name="search" id="search" value="{{ request.GET.search }}" placeholder="Ej: PROY-2026-001" style="width: 100%; padding: 0.65rem; border: 1px solid var(--efintel-border); border-radius: 4px; outline: none; font-family: 'Inter';">
</div>
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label for="status" style="font-size: 0.8rem; font-weight: 600;">Estado</label>
<select name="status" id="status" style="width: 100%; padding: 0.65rem; border: 1px solid var(--efintel-border); border-radius: 4px; outline: none; font-family: 'Inter';" onchange="document.getElementById('filter-form').submit();">
<option value="">Todos los Estados</option>
<option value="ACTIVO" {% if request.GET.status == 'ACTIVO' %}selected{% endif %}>Activo</option>
<option value="PAUSADA" {% if request.GET.status == 'PAUSADA' %}selected{% endif %}>En Pausa</option>
<option value="TERMINADO" {% if request.GET.status == 'TERMINADO' %}selected{% endif %}>Terminado</option>
<option value="BORRADOR" {% if request.GET.status == 'BORRADOR' %}selected{% endif %}>Borrador</option>
</select>
</div>
<button type="submit" class="btn-outline" style="height: 42px;"><i data-lucide="filter" style="width:16px;"></i> Filtrar</button>
{% if request.GET.search or request.GET.status %}
<a href="{% url 'project_list' %}" class="btn-secondary" style="height: 42px; line-height: 24px; text-decoration: none;">Limpiar</a>
{% endif %}
</form>
</div>
<div class="project-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem;">
{% for project in projects %}
<div class="glass-card project-card">
<div class="project-header">
<div class="project-title">{{ project.name }}</div>
<div class="data-card project-card" style="padding: 1.5rem; display:flex; flex-direction:column; justify-content:space-between; position:relative; background:white;">
<div>
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.75rem;">
<div>
<span class="badge badge-blue" style="font-family:var(--font-mono); font-size:0.7rem;">{{ project.code }}</span>
<h3 style="margin:0.25rem 0 0 0; color:var(--efintel-petroleum); font-size:1.1rem; font-weight:700;">
<a href="{% url 'project_detail' project.id %}" style="text-decoration:none; color:inherit;">{{ project.name }}</a>
</h3>
</div>
<div style="color: var(--text-secondary); font-size: 0.9rem;">
<strong>Gestor:</strong> {{ project.manager.username|default:"No asignado" }}
</div>
<div class="project-stats">
<div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;">
<div style="display: flex; justify-content: space-between;">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Presupuesto Asignado</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: var(--efintel-petroleum);">
${{ project.assigned_budget_clp|floatformat:"0"|intcomma }}
<span class="badge {% if project.status == 'ACTIVO' %}badge-emerald{% elif project.status == 'PAUSADA' %}badge-amber{% elif project.status == 'BORRADOR' %}badge-gray{% else %}badge-blue{% endif %}" style="font-size:0.7rem;">
{{ project.get_status_display }}
</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Costo Real Acumulado</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: var(--efintel-danger);">
${{ project.actual_cost_clp|floatformat:"0"|intcomma }}
<div style="color: var(--text-secondary); font-size: 0.8rem; margin-bottom: 1rem;">
<strong>Director:</strong> {{ project.manager.username|default:"No asignado" }} |
<strong>Forma Pago:</strong> {{ project.payment_method|truncatechars:20 }}
</div>
<!-- Requerimiento 9: Presupuesto, Ingresos, Egresos y Saldo -->
<div style="background:#f8fafc; padding:1rem; border-radius:6px; border:1px solid #e2e8f0; display:flex; flex-direction:column; gap:0.4rem;">
<div style="display: flex; justify-content: space-between; font-size:0.8rem;">
<span style="color: var(--efintel-gray);">Presupuesto Asignado:</span>
<span style="font-weight: 700; font-family: var(--font-mono);">${{ project.assigned_budget_clp|floatformat:"0"|intcomma }}</span>
</div>
<div style="display: flex; justify-content: space-between; font-size:0.8rem;">
<span style="color: #16a34a;">Ingresos Totales:</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: #16a34a;">+${{ project.total_income_clp|floatformat:"0"|intcomma }}</span>
</div>
<div style="display: flex; justify-content: space-between; font-size:0.8rem;">
<span style="color: #dc2626;">Egresos Acumulados:</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: #dc2626;">-${{ project.total_expenses_clp|floatformat:"0"|intcomma }}</span>
</div>
<div style="display: flex; justify-content: space-between; font-size:0.85rem; padding-top:0.4rem; border-top:1px dashed #cbd5e1; margin-top:0.2rem;">
<span style="font-weight: 700; color: var(--efintel-dark);">Saldo Neto:</span>
<span style="font-weight: 800; font-family: var(--font-mono); {% if project.balance_clp >= 0 %}color:#16a34a;{% else %}color:#dc2626;{% endif %}">
{% if project.balance_clp >= 0 %}+{% endif %}${{ project.balance_clp|floatformat:"0"|intcomma }}
</span>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--efintel-border);">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Nº Erogaciones (Facturas)</span>
<span style="font-weight: 700;">{{ project.expense_count|default:"0" }}</span>
</div>
</div>
<div style="margin-top: 1.25rem; display:flex; justify-content:space-between; align-items:center; border-top:1px solid #f1f5f9; padding-top:0.75rem;">
<span style="font-size:0.75rem; color:var(--efintel-gray);">{{ project.expense_count }} Erogaciones</span>
<div style="display:flex; gap:0.5rem;">
{% if is_admin or is_pm %}
<a href="{% url 'project_edit' project.id %}" class="btn-secondary" style="padding:0.3rem 0.6rem; font-size:0.75rem; text-decoration:none;" title="Editar Proyecto"><i data-lucide="pencil" style="width:12px;"></i></a>
{% endif %}
<a href="{% url 'project_detail' project.id %}" class="btn-primary" style="padding:0.3rem 0.8rem; font-size:0.75rem; text-decoration:none; background:var(--efintel-blue);">Ver Detalle & Fases &rarr;</a>
</div>
</div>
</div>
{% empty %}
<div class="glass-card" style="padding: 2rem; text-align: center; grid-column: 1 / -1;">
No hay proyectos registrados.
<div class="data-card" style="padding: 2rem; text-align: center; grid-column: 1 / -1;">
No hay proyectos registrados en este perfil.
</div>
{% endfor %}
</div>
@@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container" style="max-width: 700px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 1.5rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="layers"></i>
Fase de Pago / Hito para {{ project.code }} - {{ project.name }}
</h2>
<form method="post">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 3fr; gap: 1rem; margin-bottom: 1.25rem;">
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">N° Fase</label>
{{ form.phase_number }}
{% if form.phase_number.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem;">{{ form.phase_number.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Nombre del Hito / Fase</label>
{{ form.name }}
{% if form.name.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem;">{{ form.name.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem;">
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Pactado (CLP)</label>
{{ form.planned_amount_clp }}
</div>
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: #16a34a;">Ingreso Cobrado (CLP)</label>
{{ form.income_amount_clp }}
</div>
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: #dc2626;">Egreso Gastado (CLP)</label>
{{ form.expense_amount_clp }}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem;">
<div class="form-group">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Fecha Pactada / Estimada</label>
{{ form.due_date }}
</div>
<div class="form-group" style="display:flex; align-items:center; margin-top:1.5rem;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark); display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
{{ form.is_completed }} ¿Fase Completada?
</label>
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Descripción de Entregables / Hitos</label>
{{ form.description }}
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<a href="{% url 'project_detail' project.id %}" class="btn-outline" style="padding: 0.6rem 1.5rem; text-decoration: none;">Cancelar</a>
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; background: var(--efintel-blue);">Guardar Fase de Pago</button>
</div>
</form>
</div>
</div>
{% endblock %}
+14 -5
View File
@@ -8,11 +8,20 @@
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.global_budget.id_for_label }}">Presupuesto General Asignado ($)</label>
{{ form.global_budget }}
{% if form.global_budget.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.global_budget.errors }}</div>
<div class="form-group" style="margin-bottom:1.5rem;">
<label for="{{ form.iva_rate.id_for_label }}">Tasa de IVA (% Dinámico Configurable)</label>
{{ form.iva_rate }}
{% if form.iva_rate.errors %}
<div style="color: var(--efintel-danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.iva_rate.errors }}</div>
{% endif %}
<small style="color:var(--efintel-gray);">Porcentaje oficial para cálculo de impuesto IVA en facturas y órdenes de compra (Ej: 19.00).</small>
</div>
<div class="form-group" style="margin-bottom:1.5rem;">
<label for="{{ form.usd_exchange_rate.id_for_label }}">Tasa de Cambio USD / CLP por Defecto</label>
{{ form.usd_exchange_rate }}
{% if form.usd_exchange_rate.errors %}
<div style="color: var(--efintel-danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.usd_exchange_rate.errors }}</div>
{% endif %}
</div>
+122 -4
View File
@@ -3,12 +3,30 @@
{% block content %}
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<!-- Banner Requerimiento 6: Plan de mitigación de errores para registro de proveedores -->
<div id="draft-alert-banner" style="display:none; margin-bottom:1.5rem; background:#eff6ff; border:1px solid #bfdbfe; border-left:4px solid #2563eb; padding:1rem; border-radius:6px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<strong style="color:#1e40af; font-size:0.9rem;">Borrador Detectado en Almacenamiento Local (Autosave Mitigación)</strong>
<p style="margin:0.25rem 0 0 0; color:#1e3a8a; font-size:0.8rem;">Se detectaron datos no guardados de una sesión anterior. ¿Desea restaurarlos?</p>
</div>
<div style="display:flex; gap:0.5rem;">
<button type="button" onclick="restoreVendorDraft()" class="btn-primary" style="padding:0.3rem 0.8rem; font-size:0.8rem; background:#2563eb;">Restaurar</button>
<button type="button" onclick="discardVendorDraft()" class="btn-secondary" style="padding:0.3rem 0.8rem; font-size:0.8rem;">Descartar</button>
</div>
</div>
</div>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; justify-content:space-between;">
<span style="display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="{% if form.instance.pk %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if form.instance.pk %}Editar Proveedor{% else %}Nuevo Proveedor{% endif %}
{% if form.instance.pk %}Editar Proveedor{% else %}Nuevo Proveedor (Registro Seguro){% endif %}
</span>
<span class="badge badge-emerald" style="font-size:0.75rem;"><i data-lucide="shield-check" style="width:12px;"></i> Autosave / Anti-Pérdida Activo</span>
</h2>
<form method="post">
<form method="post" id="vendor-form">
{% csrf_token %}
<h3 style="margin-bottom: 1rem; color: var(--efintel-dark); font-size: 1rem;">Información General</h3>
@@ -16,7 +34,13 @@
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.rut.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">RUT Proveedor</label>
<div style="display:flex; gap:0.3rem;">
{{ form.rut }}
<button type="button" onclick="consultarSIIVendor()" class="btn-outline" style="padding:0.4rem 0.7rem; font-size:0.75rem; white-space:nowrap; background:#f0f9ff; border-color:#7dd3fc; color:#0369a1;" title="Validar y autocompletar con la API del SII">
<i data-lucide="search" style="width:12px;"></i> Consultar SII
</button>
</div>
<span id="sii-status-text" style="font-size:0.7rem; color:var(--efintel-gray); margin-top:0.25rem; display:block;"></span>
{% if form.rut.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.rut.errors }}</div>{% endif %}
</div>
@@ -70,7 +94,7 @@
</div>
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Proveedor</button>
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem; background:var(--efintel-blue);">Guardar Proveedor</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
@@ -81,5 +105,99 @@
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
// Requerimiento 6: Autosave en localStorage para mitigación de errores de formulario
const DRAFT_KEY = 'vendor_form_draft_{{ form.instance.pk|default:"new" }}';
const vendorForm = document.getElementById('vendor-form');
const draftBanner = document.getElementById('draft-alert-banner');
function saveDraft() {
const formData = {};
const elements = vendorForm.querySelectorAll('input, select, textarea');
elements.forEach(el => {
if (el.name && el.name !== 'csrfmiddlewaretoken') {
formData[el.name] = el.value;
}
});
localStorage.setItem(DRAFT_KEY, JSON.stringify(formData));
}
function checkForDraft() {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) {
try {
const data = JSON.parse(saved);
// Verify if saved data has actual input content
const hasValues = Object.values(data).some(v => v && v.trim() !== '');
if (hasValues && draftBanner) {
draftBanner.style.display = 'block';
}
} catch(e){}
}
}
function restoreVendorDraft() {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) {
const data = JSON.parse(saved);
Object.keys(data).forEach(key => {
const el = vendorForm.querySelector(`[name="${key}"]`);
if (el) el.value = data[key];
});
if (draftBanner) draftBanner.style.display = 'none';
}
}
function discardVendorDraft() {
localStorage.removeItem(DRAFT_KEY);
if (draftBanner) draftBanner.style.display = 'none';
}
vendorForm.querySelectorAll('input, select, textarea').forEach(el => {
el.addEventListener('input', saveDraft);
el.addEventListener('change', saveDraft);
});
vendorForm.addEventListener('submit', function() {
localStorage.removeItem(DRAFT_KEY);
});
// Check draft on load
checkForDraft();
// Requerimiento 4: Consulta API SII en vivo para autocompletar RUT y Razón Social
function consultarSIIVendor() {
const rutInput = document.getElementById('{{ form.rut.id_for_label }}');
const statusText = document.getElementById('sii-status-text');
if (!rutInput || !rutInput.value.trim()) {
alert('Por favor ingrese un RUT para consultar en el SII.');
return;
}
statusText.textContent = 'Consultando API SII...';
statusText.style.color = '#0284c7';
fetch(`/api/sii/taxpayer-info/?rut=${encodeURIComponent(rutInput.value.trim())}`)
.then(r => r.json())
.then(data => {
if (data.valid) {
rutInput.value = data.rut;
const nameInput = document.getElementById('{{ form.name.id_for_label }}');
const girosInput = document.getElementById('{{ form.business_line.id_for_label }}');
if (nameInput && (!nameInput.value || data.source === 'SII_API')) nameInput.value = data.name;
if (girosInput && !girosInput.value) girosInput.value = data.business_line;
statusText.textContent = `✓ RUT Validador SII OK (${data.source})`;
statusText.style.color = '#16a34a';
saveDraft();
} else {
statusText.textContent = `✗ ${data.error || 'RUT inválido'}`;
statusText.style.color = '#dc2626';
}
})
.catch(err => {
statusText.textContent = 'Error al consultar API SII.';
statusText.style.color = '#dc2626';
});
}
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% if form.non_field_errors %}
<div class="alert alert-danger" style="margin-bottom: 1rem;">
{% for err in form.non_field_errors %}
<p style="margin:0;">{{ err }}</p>
{% endfor %}
</div>
{% endif %}
<div class="form-grid">
{% for field in form %}
<div class="form-group {% if 'summary' in field.name or 'address' in field.name or 'description' in field.name or 'feedback' in field.name %}full-width{% endif %}">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<small class="form-help">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="form-error">
{% for err in field.errors %}
<span>{{ err }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="user-plus" style="color:var(--efintel-blue);"></i> {{ title }}</h2>
<p>Alta y modificación de ficha de colaborador en el sistema de remuneraciones EFINTEL.</p>
</div>
<div>
<a href="{% url 'payroll:employee_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:850px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'payroll:employee_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="save" style="width:16px;"></i> Guardar Ficha de Colaborador</button>
</div>
</form>
</div>
</section>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="users" style="color:var(--efintel-blue);"></i> Maestro de Colaboradores — Remuneraciones</h2>
<p>Ficha del colaborador, contratos, previsión (AFP/Fonasa/Isapre), y cuenta bancaria de abono.</p>
</div>
<div>
<a href="{% url 'payroll:employee_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="user-plus" style="width:16px;"></i> + Nuevo Colaborador
</a>
</div>
</div>
<div class="data-card" x-data="{ search: '' }">
<div style="padding:1rem; border-bottom:1px solid var(--border-color);">
<input type="text" x-model="search" placeholder="Buscar por Nombre, RUT o Cargo..." class="form-control" style="width:100%; max-width:400px;">
</div>
<table class="data-table">
<thead>
<tr>
<th>RUT</th>
<th>Colaborador</th>
<th>Cargo</th>
<th>Tipo Contrato</th>
<th>Sueldo Base (CLP)</th>
<th>Previsión (AFP / Salud)</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for emp in employees %}
<tr x-show="search === '' || '{{ emp.full_name|lower }} {{ emp.rut|lower }} {{ emp.job_title|lower }}'.includes(search.toLowerCase())">
<td><strong>{{ emp.rut }}</strong></td>
<td>{{ emp.full_name }}</td>
<td>{{ emp.job_title }}</td>
<td><span class="badge badge-blue">{{ emp.get_contract_type_display }}</span></td>
<td>${{ emp.base_salary_clp|floatformat:"0"|intcomma }} CLP</td>
<td>{{ emp.get_afp_display }} / {{ emp.get_health_system_display }}</td>
<td>
<a href="{% url 'payroll:employee_edit' emp.pk %}" class="btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem; text-decoration:none;">
Editar Ficha
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay colaboradores registrados. <a href="{% url 'payroll:employee_create' %}">Registrar el primero</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="cpu" style="color:var(--efintel-blue);"></i> {{ title|default:"Motor de Cálculo Mensual de Remuneraciones" }}</h2>
<p>Procesamiento de sueldos imponibles, leyes sociales (AFP, Salud, AFC) y retenciones de impuestos.</p>
</div>
<div>
<a href="{% url 'payroll:slip_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:750px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'payroll:slip_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="zap" style="width:16px;"></i> Ejecutar Motor de Cálculo</button>
</div>
</form>
</div>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="cpu" style="color:var(--efintel-blue);"></i> Motor de Cálculo Mensual de Remuneraciones</h2>
<p>Procesamiento masivo o individual de liquidaciones, leyes sociales (AFP, Fonasa/Isapre) e impuestos.</p>
</div>
<div>
<a href="{% url 'payroll:slip_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:750px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'payroll:slip_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="zap" style="width:16px;"></i> Ejecutar Motor de Cálculo</button>
</div>
</form>
</div>
</section>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-check" style="color:var(--efintel-blue);"></i> Liquidaciones de Sueldo Mensuales</h2>
<p>Procesamiento mensual de haberes, descuentos, emisión en PDF y libro de remuneraciones (LRE).</p>
</div>
<div style="display:flex; gap:0.5rem;">
<a href="{% url 'payroll:slip_generate' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="cpu" style="width:16px;"></i> Generar Cálculo Mensual
</a>
<a href="{% url 'payroll:export_previred' %}" class="btn-primary" style="background:#107c41; text-decoration:none;">
<i data-lucide="download" style="width:16px;"></i> Archivo Previred
</a>
</div>
</div>
<div class="data-card" x-data="{ search: '' }">
<div style="padding:1rem; border-bottom:1px solid var(--border-color); display:flex; gap:1rem; align-items:flex-end;">
<form method="get" style="display:flex; gap:1rem; align-items:flex-end;">
<div>
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Mes</label>
<select name="month" class="form-control" style="min-width:120px;">
<option value="">Todos</option>
<option value="1" {% if selected_month == '1' %}selected{% endif %}>Enero</option>
<option value="2" {% if selected_month == '2' %}selected{% endif %}>Febrero</option>
<option value="3" {% if selected_month == '3' %}selected{% endif %}>Marzo</option>
<option value="4" {% if selected_month == '4' %}selected{% endif %}>Abril</option>
<option value="5" {% if selected_month == '5' %}selected{% endif %}>Mayo</option>
<option value="6" {% if selected_month == '6' %}selected{% endif %}>Junio</option>
<option value="7" {% if selected_month == '7' %}selected{% endif %}>Julio</option>
<option value="8" {% if selected_month == '8' %}selected{% endif %}>Agosto</option>
<option value="9" {% if selected_month == '9' %}selected{% endif %}>Septiembre</option>
<option value="10" {% if selected_month == '10' %}selected{% endif %}>Octubre</option>
<option value="11" {% if selected_month == '11' %}selected{% endif %}>Noviembre</option>
<option value="12" {% if selected_month == '12' %}selected{% endif %}>Diciembre</option>
</select>
</div>
<div>
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Año</label>
<input type="number" name="year" value="{{ selected_year }}" class="form-control" min="2000" max="2100" style="width:100px;">
</div>
<div>
<button type="submit" class="btn-primary" style="padding:0.5rem 1rem;">Filtrar</button>
</div>
</form>
<div style="flex:1;"></div>
<div>
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Buscar Colaborador</label>
<input type="text" x-model="search" placeholder="Nombre o RUT..." class="form-control" style="min-width:250px;">
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>Mes/Año</th>
<th>Colaborador</th>
<th>Haberes Imponibles</th>
<th>Descuentos</th>
<th>Sueldo Líquido (CLP)</th>
<th>Firma Electrónica</th>
</tr>
</thead>
<tbody>
{% for slip in slips %}
<tr x-show="search === '' || '{{ slip.employee.full_name|lower }} {{ slip.employee.rut|lower }}'.includes(search.toLowerCase())">
<td><strong>{{ slip.month }}/{{ slip.year }}</strong></td>
<td>{{ slip.employee.full_name }}</td>
<td>${{ slip.taxable_earnings_clp|floatformat:"0"|intcomma }} CLP</td>
<td>${{ slip.total_deductions_clp|floatformat:"0"|intcomma }} CLP</td>
<td><strong>${{ slip.net_salary_clp|floatformat:"0"|intcomma }} CLP</strong></td>
<td>
{% if slip.is_signed %}
<span class="badge badge-emerald">Firmada</span>
{% else %}
<span class="badge badge-turquoise">Pendiente Firma</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay liquidaciones emitidas para el periodo actual.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="package-plus" style="color:var(--efintel-blue);"></i> {{ title|default:"Ítem de Catálogo de Compras" }}</h2>
<p>Parametrización de bienes, insumos, arriendos (vehículos/oficinas) y licencias técnicas.</p>
</div>
<div>
<a href="{% url 'procurement:catalog_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'procurement:catalog_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="save" style="width:16px;"></i> Guardar Ítem de Catálogo</button>
</div>
</form>
</div>
</section>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="shopping-cart" style="color:var(--efintel-blue);"></i> Catálogo de Ítems y Servicios de Compras</h2>
<p>Catálogo homologado de bienes, insumos, arriendos y servicios para requisiciones u órdenes de compra.</p>
</div>
<div>
<a href="{% url 'procurement:catalog_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Nuevo Ítem de Catálogo
</a>
</div>
</div>
<div class="filter-bar" style="margin-bottom: 1.5rem;">
<form method="get" class="d-flex" style="gap: 1rem; align-items: flex-end;">
<div class="form-group" style="flex: 1;">
<label for="q">Buscar (Código/Nombre)</label>
<input type="text" name="q" id="q" class="form-control" value="{{ query }}" placeholder="Ej. Laptops, CAT-001...">
</div>
<div class="form-group" style="flex: 1;">
<label for="category">Categoría</label>
<select name="category" id="category" class="form-control">
<option value="">Todas</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat.id|stringformat:"s" == selected_cat %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<button type="submit" class="btn-primary" style="height: 38px;">Filtrar</button>
</div>
</form>
</div>
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Descripción</th>
<th>Unidad</th>
<th>Precio Ref. (CLP)</th>
<th>SLA (Días)</th>
<th>Proveedor Preferido</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td><strong>{{ item.code }}</strong></td>
<td>{{ item.name }}</td>
<td>{{ item.get_unit_display }}</td>
<td>${{ item.reference_price_clp|floatformat:"0"|intcomma }} CLP</td>
<td>{{ item.sla_days }} días</td>
<td>{{ item.preferred_vendor.name|default:"Sin asignar" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay ítems registrados en el catálogo. <a href="{% url 'procurement:catalog_create' %}">Crear el primero</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-check-2" style="color:var(--efintel-blue);"></i> {{ title|default:"Generar Orden de Compra (OC)" }}</h2>
<p>Emisión oficial de Orden de Compra formal para envío a proveedor y conciliación posterior.</p>
</div>
<div>
<a href="{% url 'procurement:purchase_order_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'procurement:purchase_order_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="printer" style="width:16px;"></i> Emitir Orden de Compra</button>
</div>
</form>
</div>
</section>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="package" style="color:var(--efintel-blue);"></i> Órdenes de Compra (OC)</h2>
<p>Emisión, envío a proveedores, recepciones conformes y conciliación 3-Way Match.</p>
</div>
<div>
<a href="{% url 'procurement:purchase_order_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Generar OC
</a>
</div>
</div>
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>Código OC</th>
<th>Proveedor</th>
<th>Proyecto</th>
<th>Total Neto (CLP)</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for po in purchase_orders %}
<tr>
<td><strong>{{ po.code }}</strong></td>
<td>{{ po.vendor.name }}</td>
<td>{{ po.project.name }}</td>
<td>${{ po.net_amount_clp|floatformat:"0"|intcomma }} CLP</td>
<td><span class="badge badge-blue">{{ po.get_status_display }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay órdenes de compra registradas.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-check-2" style="color:var(--efintel-blue);"></i> Generar Orden de Compra (OC)</h2>
<p>Emisión oficial de Orden de Compra formal para envío a proveedor y conciliación 3-Way Match.</p>
</div>
<div>
<a href="{% url 'procurement:purchase_order_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form" x-data="chileRegions()">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<!-- Alpine JS for Region and Comuna injection into delivery_address -->
<div style="margin-top: 1rem; padding: 1rem; border: 1px solid var(--border-color); border-radius: 4px;">
<label>Asistente de Dirección (Región/Comuna)</label>
<div style="display: flex; gap: 1rem; margin-top: 0.5rem;">
<select x-model="selectedRegion" @change="updateAddress()" class="form-control" style="flex: 1;">
<option value="">-- Seleccionar Región --</option>
<template x-for="(communes, region) in regions" :key="region">
<option :value="region" x-text="region"></option>
</template>
</select>
<select x-model="selectedComuna" @change="updateAddress()" class="form-control" style="flex: 1;" :disabled="!selectedRegion">
<option value="">-- Seleccionar Comuna --</option>
<template x-for="comuna in getCommunes()" :key="comuna">
<option :value="comuna" x-text="comuna"></option>
</template>
</select>
</div>
<small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
Al seleccionar región y comuna, se agregarán automáticamente al campo "Lugar de Entrega".
</small>
</div>
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'procurement:purchase_order_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="printer" style="width:16px;"></i> Emitir Orden de Compra</button>
</div>
</form>
</div>
</section>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('chileRegions', () => ({
regions: {
"Región de Arica y Parinacota": ["Arica", "Camarones", "Putre", "General Lagos"],
"Región de Tarapacá": ["Iquique", "Alto Hospicio", "Pozo Almonte", "Camiña", "Colchane", "Huara", "Pica"],
"Región de Antofagasta": ["Antofagasta", "Mejillones", "Sierra Gorda", "Taltal", "Calama", "Ollagüe", "San Pedro de Atacama", "Tocopilla", "María Elena"],
"Región de Atacama": ["Copiapó", "Caldera", "Tierra Amarilla", "Chañaral", "Diego de Almagro", "Vallenar", "Alto del Carmen", "Freirina", "Huasco"],
"Región de Coquimbo": ["La Serena", "Coquimbo", "Andacollo", "La Higuera", "Paihuano", "Vicuña", "Illapel", "Canela", "Los Vilos", "Salamanca", "Ovalle", "Combarbalá", "Monte Patria", "Punitaqui", "Río Hurtado"],
"Región de Valparaíso": ["Valparaíso", "Casablanca", "Concón", "Juan Fernández", "Puchuncaví", "Quintero", "Viña del Mar", "Isla de Pascua", "Los Andes", "Calle Larga", "Rinconada", "San Esteban", "La Ligua", "Cabildo", "Papudo", "Petorca", "Zapallar", "Quillota", "Calera", "Hijuelas", "La Cruz", "Nogales", "San Antonio", "Algarrobo", "Cartagena", "El Quisco", "El Tabo", "Santo Domingo", "San Felipe", "Catemu", "Llaillay", "Panquehue", "Putaendo", "Santa María", "Quilpué", "Limache", "Olmué", "Villa Alemana"],
"Región Metropolitana de Santiago": ["Cerrillos", "Cerro Navia", "Conchalí", "El Bosque", "Estación Central", "Huechuraba", "Independencia", "La Cisterna", "La Florida", "La Granja", "La Pintana", "La Reina", "Las Condes", "Lo Barnechea", "Lo Espejo", "Lo Prado", "Macul", "Maipú", "Ñuñoa", "Pedro Aguirre Cerda", "Peñalolén", "Providencia", "Pudahuel", "Quilicura", "Quinta Normal", "Recoleta", "Renca", "Santiago", "San Joaquín", "San Miguel", "San Ramón", "Vitacura", "Puente Alto", "Pirque", "San José de Maipo", "Colina", "Lampa", "Tiltil", "San Bernardo", "Buin", "Calera de Tango", "Paine", "Melipilla", "Alhué", "Curacaví", "María Pinto", "San Pedro", "Talagante", "El Monte", "Isla de Maipo", "Padre Hurtado", "Peñaflor"],
"Región del Libertador General Bernardo O'Higgins": ["Rancagua", "Codegua", "Coinco", "Coltauco", "Doñihue", "Graneros", "Las Cabras", "Machalí", "Malloa", "Mostazal", "Olivar", "Peumo", "Pichidegua", "Quinta de Tilcoco", "Rengo", "Requínoa", "San Vicente", "Pichilemu", "La Estrella", "Litueche", "Marchihue", "Navidad", "Paredones", "San Fernando", "Chépica", "Chimbarongo", "Lolol", "Nancagua", "Palmilla", "Peralillo", "Placilla", "Pumanque", "Santa Cruz"],
"Región del Maule": ["Talca", "Constitución", "Curepto", "Empedrado", "Maule", "Pelarco", "Pencahue", "Río Claro", "San Clemente", "San Rafael", "Cauquenes", "Chanco", "Pelluhue", "Curicó", "Hualañé", "Licantén", "Molina", "Rauco", "Romeral", "Sagrada Familia", "Teno", "Vichuquén", "Linares", "Colbún", "Longaví", "Parral", "Retiro", "San Javier", "Villa Alegre", "Yerbas Buenas"],
"Región de Ñuble": ["Cobquecura", "Coelemu", "Ninhue", "Portezuelo", "Quirihue", "Ránquil", "Treguaco", "Bulnes", "Chillán Viejo", "Chillán", "El Carmen", "Pemuco", "Pinto", "Quillón", "San Ignacio", "Yungay", "Coihueco", "Ñiquén", "San Carlos", "San Fabián", "San Nicolás"],
"Región del Biobío": ["Concepción", "Coronel", "Chiguayante", "Florida", "Hualqui", "Lota", "Penco", "San Pedro de la Paz", "Santa Juana", "Talcahuano", "Tomé", "Hualpén", "Lebu", "Arauco", "Cañete", "Contulmo", "Curanilahue", "Los Álamos", "Tirúa", "Los Ángeles", "Antuco", "Cabrero", "Laja", "Mulchén", "Nacimiento", "Negrete", "Quilaco", "Quilleco", "San Rosendo", "Santa Bárbara", "Tucapel", "Yumbel", "Alto Biobío"],
"Región de La Araucanía": ["Temuco", "Carahue", "Cunco", "Curarrehue", "Freire", "Galvarino", "Gorbea", "Lautaro", "Loncoche", "Melipeuco", "Nueva Imperial", "Padre las Casas", "Perquenco", "Pitrufquén", "Pucón", "Saavedra", "Teodoro Schmidt", "Toltén", "Vilcún", "Villarrica", "Cholchol", "Angol", "Collipulli", "Curacautín", "Ercilla", "Lonquimay", "Los Sauces", "Lumaco", "Purén", "Renaico", "Traiguén", "Victoria"],
"Región de Los Ríos": ["Valdivia", "Corral", "Lanco", "Los Lagos", "Máfil", "Mariquina", "Paillaco", "Panguipulli", "La Unión", "Futrono", "Lago Ranco", "Río Bueno"],
"Región de Los Lagos": ["Puerto Montt", "Calbuco", "Cochamó", "Fresia", "Frutillar", "Los Muermos", "Llanquihue", "Maullín", "Puerto Varas", "Castro", "Ancud", "Chonchi", "Curaco de Vélez", "Dalcahue", "Puqueldón", "Queilén", "Quellón", "Quemchi", "Quinchao", "Osorno", "Puerto Octay", "Purranque", "Puyehue", "Río Negro", "San Juan de la Costa", "San Pablo", "Chaitén", "Futaleufú", "Hualaihué", "Palena"],
"Región de Aysén del General Carlos Ibáñez del Campo": ["Coihaique", "Lago Verde", "Aysén", "Cisnes", "Guaitecas", "Cochrane", "O'Higgins", "Tortel", "Chile Chico", "Río Ibáñez"],
"Región de Magallanes y de la Antártica Chilena": ["Punta Arenas", "Laguna Blanca", "Río Verde", "San Gregorio", "Cabo de Hornos", "Antártica", "Porvenir", "Primavera", "Timaukel", "Natales", "Torres del Paine"]
},
selectedRegion: '',
selectedComuna: '',
getCommunes() {
return this.selectedRegion ? this.regions[this.selectedRegion] : [];
},
updateAddress() {
const addressInput = document.getElementById('id_delivery_address');
if (addressInput) {
let current = addressInput.value.split(' - ')[0]; // keep the base address if present
if (this.selectedRegion && this.selectedComuna) {
addressInput.value = `${current} - ${this.selectedComuna}, ${this.selectedRegion}`;
}
}
}
}));
});
</script>
{% endblock %}
@@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="package" style="color:var(--efintel-blue);"></i> Órdenes de Compra (OC)</h2>
<p>Emisión, envío a proveedores, recepciones conformes y conciliación 3-Way Match.</p>
</div>
<div>
<a href="{% url 'procurement:purchase_order_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Generar OC
</a>
</div>
</div>
<div class="filter-bar" style="margin-bottom: 1.5rem;">
<form method="get" class="d-flex" style="gap: 1rem; align-items: flex-end;">
<div class="form-group" style="flex: 1;">
<label for="status">Estado</label>
<select name="status" id="status" class="form-control">
<option value="">Todos los Estados</option>
<option value="EMITIDA" {% if selected_status == 'EMITIDA' %}selected{% endif %}>Emitida</option>
<option value="ENVIADA_PROVEEDOR" {% if selected_status == 'ENVIADA_PROVEEDOR' %}selected{% endif %}>Enviada a Proveedor</option>
<option value="RECEPCION_PARCIAL" {% if selected_status == 'RECEPCION_PARCIAL' %}selected{% endif %}>Recepción Parcial</option>
<option value="RECEPCION_TOTAL" {% if selected_status == 'RECEPCION_TOTAL' %}selected{% endif %}>Recepción Total</option>
<option value="CONCILIADA_MATCH" {% if selected_status == 'CONCILIADA_MATCH' %}selected{% endif %}>Conciliada (3-Way Match)</option>
<option value="CERRADA" {% if selected_status == 'CERRADA' %}selected{% endif %}>Cerrada</option>
<option value="CANCELADA" {% if selected_status == 'CANCELADA' %}selected{% endif %}>Cancelada</option>
</select>
</div>
<div class="form-group">
<button type="submit" class="btn-primary" style="height: 38px;">Filtrar</button>
</div>
</form>
</div>
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>Código OC</th>
<th>Proveedor</th>
<th>Proyecto</th>
<th>Total Neto (CLP)</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for po in orders %}
<tr>
<td><strong>{{ po.code }}</strong></td>
<td>{{ po.vendor.name }}</td>
<td>{{ po.project.name }}</td>
<td>${{ po.total_net_clp|floatformat:"0"|intcomma }} CLP</td>
<td><span class="badge badge-blue">{{ po.get_status_display }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay órdenes de compra registradas.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-plus-2" style="color:var(--efintel-blue);"></i> {{ title|default:"Nueva Solicitud / Requisición de Compra" }}</h2>
<p>Formulario de requerimiento de insumos o servicios con centro de costos e imputación a proyecto I+D+i.</p>
</div>
<div>
<a href="{% url 'procurement:requisition_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'procurement:requisition_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="send" style="width:16px;"></i> Enviar Requisición a Aprobación</button>
</div>
</form>
</div>
</section>
{% endblock %}
@@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="file-text" style="color:var(--efintel-blue);"></i> Solicitudes y Requisiciones de Compra</h2>
<p>Gestión del flujo de requerimientos de abastecimiento I+D+i y aprobación técnica/financiera.</p>
</div>
<div>
<a href="{% url 'procurement:requisition_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Nueva Requisición
</a>
</div>
</div>
<div class="filter-bar" style="margin-bottom: 1.5rem;">
<form method="get" class="d-flex" style="gap: 1rem; align-items: flex-end;">
<div class="form-group" style="flex: 1;">
<label for="status">Estado</label>
<select name="status" id="status" class="form-control">
<option value="">Todos los Estados</option>
<option value="SOLICITADA" {% if selected_status == 'SOLICITADA' %}selected{% endif %}>Solicitada</option>
<option value="APROBADA_TECNICA" {% if selected_status == 'APROBADA_TECNICA' %}selected{% endif %}>Aprobada Técnica</option>
<option value="APROBADA_FINANZAS" {% if selected_status == 'APROBADA_FINANZAS' %}selected{% endif %}>Aprobada Finanzas</option>
<option value="RECHAZADA" {% if selected_status == 'RECHAZADA' %}selected{% endif %}>Rechazada</option>
<option value="CONVERTIDA_OC" {% if selected_status == 'CONVERTIDA_OC' %}selected{% endif %}>Convertida a OC</option>
</select>
</div>
<div class="form-group">
<button type="submit" class="btn-primary" style="height: 38px;">Filtrar</button>
</div>
</form>
</div>
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Solicitante</th>
<th>Proyecto</th>
<th>Prioridad</th>
<th>Total Est. (CLP)</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for req in requisitions %}
<tr>
<td><strong>{{ req.code }}</strong></td>
<td>{{ req.requester.username }}</td>
<td>{{ req.project.code }} - {{ req.project.name }}</td>
<td><span class="badge badge-turquoise">{{ req.get_priority_display }}</span></td>
<td>${{ req.total_estimated_clp|floatformat:"0"|intcomma }} CLP</td>
<td><span class="badge badge-blue">{{ req.get_status_display }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay requisiciones registradas. <a href="{% url 'procurement:requisition_create' %}">Crear la primera</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="user" style="color:var(--efintel-blue);"></i> Ficha Técnica: {{ candidate.full_name }}</h2>
<p>RUT: {{ candidate.rut }} | {{ candidate.specialty_title }}</p>
</div>
<div style="display:flex; gap:0.5rem;">
<a href="{% url 'talent:candidate_edit' candidate.pk %}" class="btn-primary" style="background:var(--efintel-turquoise); text-decoration:none;">
<i data-lucide="edit-3" style="width:16px;"></i> Editar
</a>
<a href="{% url 'talent:candidate_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="kpi-grid-3" style="margin-bottom:1.5rem;">
<div class="kpi-card accent-blue">
<div class="kpi-header"><span class="kpi-title">TAXONOMÍA PRINCIPAL</span><div class="kpi-icon"><i data-lucide="layers"></i></div></div>
<div class="kpi-value" style="font-size:1.1rem;">{{ candidate.get_category_code_display }}</div>
</div>
<div class="kpi-card accent-turquoise">
<div class="kpi-header"><span class="kpi-title">MADUREZ TECNOLÓGICA</span><div class="kpi-icon"><i data-lucide="cpu"></i></div></div>
<div class="kpi-value">TRL {{ candidate.trl_level }}</div>
<div class="kpi-subtext"><span>Seniority: {{ candidate.get_seniority_display }}</span></div>
</div>
<div class="kpi-card accent-emerald">
<div class="kpi-header"><span class="kpi-title">ESTADO EN CARTERA</span><div class="kpi-icon"><i data-lucide="shield-check"></i></div></div>
<div class="kpi-value" style="font-size:1.2rem;">{{ candidate.get_status_display }}</div>
</div>
</div>
<div class="data-card" style="padding:1.5rem;">
<h3>Detalles del Perfil</h3>
<ul style="line-height:2;">
<li><strong>Correo:</strong> {{ candidate.email }}</li>
<li><strong>Teléfono:</strong> {{ candidate.phone|default:"No informado" }}</li>
<li><strong>Ciudad/Región:</strong> {{ candidate.city }}</li>
<li><strong>Pretensión de Renta:</strong> ${{ candidate.monthly_salary_target_clp|floatformat:"0"|intcomma }} CLP</li>
<li><strong>Disponibilidad:</strong> {{ candidate.get_availability_display }}</li>
{% if candidate.github_url %}<li><strong>GitHub:</strong> <a href="{{ candidate.github_url }}" target="_blank">{{ candidate.github_url }}</a></li>{% endif %}
{% if candidate.linkedin_url %}<li><strong>LinkedIn:</strong> <a href="{{ candidate.linkedin_url }}" target="_blank">{{ candidate.linkedin_url }}</a></li>{% endif %}
</ul>
</div>
</section>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="user-check" style="color:var(--efintel-blue);"></i> {{ title }}</h2>
<p>Formulario de alta y edición de especialista I+D+i con taxonomía (categorías 1 a 7) y nivel TRL.</p>
</div>
<div>
<a href="{% url 'talent:candidate_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver a Cartera
</a>
</div>
</div>
<div class="data-card" style="max-width:800px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for err in form.non_field_errors %}
<p>{{ err }}</p>
{% endfor %}
</div>
{% endif %}
<div class="form-grid" x-data="regionComunaData()">
{% for field in form %}
{% if field.name == 'city' %}
<div class="form-group">
<label for="region_select" class="form-label">Región</label>
<select id="region_select" x-model="selectedRegion" @change="updateComunas" class="form-control" required>
<option value="">Seleccione Región...</option>
<template x-for="(comunas, region) in regionsData" :key="region">
<option :value="region" x-text="region" :selected="region === selectedRegion"></option>
</template>
</select>
</div>
<div class="form-group">
<label for="comuna_select" class="form-label">Comuna / Ciudad</label>
<select id="comuna_select" x-model="selectedComuna" @change="updateCity" class="form-control" required>
<option value="">Seleccione Comuna...</option>
<template x-for="comuna in availableComunas" :key="comuna">
<option :value="comuna" x-text="comuna" :selected="comuna === selectedComuna"></option>
</template>
</select>
</div>
<!-- Hidden original field to be submitted -->
<input type="hidden" name="city" id="id_city" x-model="finalCity" />
{% else %}
<div class="form-group {% if field.name == 'summary' or field.name == 'address' %}full-width{% endif %}">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<small class="form-help">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="form-error">
{% for err in field.errors %}
<span>{{ err }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<script>
function regionComunaData() {
const data = {{ regions_json|safe }};
const initialCity = "{{ form.city.value|default:'' }}";
let initReg = "";
let initCom = "";
if (initialCity) {
// Find which region has this comuna
for (const [reg, coms] of Object.entries(data)) {
if (coms.includes(initialCity)) {
initReg = reg;
initCom = initialCity;
break;
}
}
if (!initReg) {
// If not found in communes, might just be free text or old data.
initCom = initialCity;
}
}
return {
regionsData: data,
selectedRegion: initReg,
selectedComuna: initCom,
availableComunas: initReg ? data[initReg] : [],
finalCity: initialCity,
updateComunas() {
this.availableComunas = this.selectedRegion ? this.regionsData[this.selectedRegion] : [];
this.selectedComuna = "";
this.finalCity = "";
},
updateCity() {
this.finalCity = this.selectedComuna;
}
}
}
</script>
<div class="form-actions">
<a href="{% url 'talent:candidate_list' %}" class="btn-secondary">
Cancelar
</a>
<button type="submit" class="btn-primary">
<i data-lucide="save"></i> Guardar Especialista
</button>
</div>
</form>
</div>
</section>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="award" style="color:var(--efintel-blue);"></i> Cartera de Especialistas — Talent Pool I+D+i</h2>
<p>Directorio maestro, clasificación por taxonomía (1-7), nivel TRL y rúbrica de evaluación 7D.</p>
</div>
<div>
<a href="{% url 'talent:candidate_create' %}" class="btn-primary" style="text-decoration:none;">
<i data-lucide="user-plus" style="width:16px;"></i> + Nuevo Especialista
</a>
</div>
</div>
<!-- KPI Cards -->
<div class="kpi-grid-3" style="margin-bottom: 1.5rem;">
<div class="kpi-card accent-blue">
<div class="kpi-header"><span class="kpi-title">TOTAL CANDIDATOS</span><div class="kpi-icon"><i data-lucide="users"></i></div></div>
<div class="kpi-value">{{ stats.total|default:"0" }}</div>
<div class="kpi-subtext"><span>Postulantes en Sistema</span></div>
</div>
<div class="kpi-card accent-emerald">
<div class="kpi-header"><span class="kpi-title">APROBADOS EN CARTERA</span><div class="kpi-icon"><i data-lucide="check-circle-2"></i></div></div>
<div class="kpi-value">{{ stats.approved|default:"0" }}</div>
<div class="kpi-subtext"><span>Listos para Asignación</span></div>
</div>
<div class="kpi-card accent-turquoise">
<div class="kpi-header"><span class="kpi-title">EN RESERVA / EVALUACIÓN</span><div class="kpi-icon"><i data-lucide="clock"></i></div></div>
<div class="kpi-value">{{ stats.reserve|add:stats.evaluating|default:"0" }}</div>
<div class="kpi-subtext"><span>Banco de Talento Vigoroso</span></div>
</div>
</div>
<!-- Filters -->
<div class="data-card" style="margin-bottom: 1.5rem; padding: 1.5rem;">
<form method="get" class="filter-form" style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end;">
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label">Buscar</label>
<input type="text" name="q" class="form-control" value="{{ query|default:'' }}" placeholder="Nombre, RUT, especialidad...">
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label">Categoría</label>
<select name="category" class="form-control">
<option value="">Todas</option>
{% for val, name in categories %}
<option value="{{ val }}" {% if val == selected_category %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label">Seniority</label>
<select name="seniority" class="form-control">
<option value="">Todos</option>
{% for val, name in seniorities %}
<option value="{{ val }}" {% if val == selected_seniority %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label">Estado</label>
<select name="status" class="form-control">
<option value="">Todos</option>
{% for val, name in statuses %}
<option value="{{ val }}" {% if val == selected_status %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<button type="submit" class="btn-primary" style="height: 42px;">Filtrar</button>
<a href="{% url 'talent:candidate_list' %}" class="btn-secondary" style="height: 42px; line-height:26px;">Limpiar</a>
</div>
</form>
</div>
<!-- Data Table -->
<div class="data-card">
<table class="data-table">
<thead>
<tr>
<th>RUT</th>
<th>Especialista</th>
<th>Categoría Taxonomía</th>
<th>Seniority</th>
<th>TRL</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for c in candidates %}
<tr>
<td><strong>{{ c.rut }}</strong></td>
<td>
<a href="{% url 'talent:candidate_detail' c.pk %}" style="text-decoration:none; color:var(--efintel-blue); font-weight:600;">
{{ c.full_name }}
</a>
<div style="font-size:0.75rem; color:var(--text-secondary);">{{ c.specialty_title }}</div>
</td>
<td><span class="badge badge-blue">{{ c.get_category_code_display }}</span></td>
<td>{{ c.get_seniority_display }}</td>
<td><span class="badge badge-turquoise">TRL {{ c.trl_level }}</span></td>
<td>
{% if c.status == 'APROBADO_CARTERA' %}
<span class="badge badge-emerald">Aprobado</span>
{% elif c.status == 'EN_RESERVA' %}
<span class="badge badge-turquoise">En Reserva</span>
{% else %}
<span class="badge badge-gray">{{ c.get_status_display }}</span>
{% endif %}
</td>
<td>
<a href="{% url 'talent:candidate_detail' c.pk %}" class="btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem; text-decoration:none;">
Ver Ficha
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-secondary);">
No hay especialistas registrados en el sistema. <a href="{% url 'talent:candidate_create' %}">Registrar primero</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="clipboard-check" style="color:var(--efintel-blue);"></i> Evaluación 7D de Especialista I+D+i</h2>
<p>Rúbrica holística en 7 dimensiones ponderadas (Caso práctico 25%, Portafolio 15%, Competencias 15%, etc.).</p>
</div>
<div>
<a href="{% url 'talent:candidate_list' %}" class="btn-primary" style="background:var(--efintel-gray); text-decoration:none;">
<i data-lucide="arrow-left" style="width:16px;"></i> Volver
</a>
</div>
</div>
<div class="data-card" style="max-width:850px; margin:0 auto; padding:2rem;">
<form method="post" class="modern-form">
{% csrf_token %}
{% include "partials/_form_grid.html" %}
<div style="margin-top:1.5rem; display:flex; gap:1rem; justify-content:flex-end;">
<a href="{% url 'talent:candidate_list' %}" class="btn-secondary" style="text-decoration:none;">Cancelar</a>
<button type="submit" class="btn-primary"><i data-lucide="check-square" style="width:16px;"></i> Procesar Evaluación 7D</button>
</div>
</form>
</div>
</section>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
import os
import re
def process_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if '{{ form.as_p }}' in content:
# replace {{ form.as_p }} with {% include 'partials/_form_grid.html' %}
content = content.replace('{{ form.as_p }}', '{% include "partials/_form_grid.html" %}')
# also try to add modern-form class to <form>
# match <form ...> that doesn't have class="modern-form"
content = re.sub(r'<form\s+method="post"(?!.*?class="modern-form")', r'<form method="post" class="modern-form"', content)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Updated {filepath}")
for root, dirs, files in os.walk('templates'):
for file in files:
if file.endswith('.html') and file != '_form_grid.html':
process_file(os.path.join(root, file))